Replace 30 scattered `useAtomRefresh()` call sites with effect-atom's `reactivityKeys` invalidation. Every query declares its key at the atom definition; every mutation passes the matching key at the call site, and effect-atom's Reactivity service auto-refetches on mutation completion. Adds a client-only optimistic-update layer (`usePendingSources` + `useSourcesWithPending`) so the sidebar shows a placeholder row the instant a user submits an add form, rather than waiting for the network roundtrip. A CI lint script (`lint:reactivity-keys`) fails any build that defines a write mutation call without `reactivityKeys`, locking the convention in. Key files: - packages/react/src/api/reactivity-keys.tsx — canonical key constants - packages/react/src/api/optimistic.tsx — pending-resource atom family - scripts/check-reactivity-keys.ts — CI enforcement
Unified · Split
| @@ -1,9 +1,14 @@ | |||
| 1 | import { useReducer, useState } from "react"; | 1 | import { useReducer, useState } from "react"; |
| 2 | import { Exit } from "effect"; | 2 | import { Exit } from "effect"; |
| 3 | import { createFileRoute, Link } from "@tanstack/react-router"; | 3 | import { createFileRoute, Link } from "@tanstack/react-router"; |
| 4 | import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 4 | import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; |
| 5 | import { useCustomer } from "autumn-js/react"; | 5 | import { useCustomer } from "autumn-js/react"; |
| 6 | import { toast } from "sonner"; | 6 | import { toast } from "sonner"; |
| 7 | import { | ||
| 8 | orgMemberWriteKeys, | ||
| 9 | orgDomainWriteKeys, | ||
| 10 | orgInfoWriteKeys, | ||
| 11 | } from "@executor/react/api/reactivity-keys"; | ||
| 7 | import { | 12 | import { |
| 8 | Dialog, | 13 | Dialog, |
| 9 | DialogContent, | 14 | DialogContent, |
| @@ -46,7 +51,7 @@ import { · 36 unmodified lines | |||
| 46 | deleteDomain, | 51 | deleteDomain, |
| 47 | updateOrgName, | 52 | updateOrgName, |
| 48 | } from "../web/org-atoms"; | 53 | } from "../web/org-atoms"; |
| 49 | import { authAtom, useAuth } from "../web/auth"; | 54 | import { useAuth } from "../web/auth"; |
| 50 | 55 | ||
| 51 | export const Route = createFileRoute("/org")({ | 56 | export const Route = createFileRoute("/org")({ |
| 52 | component: OrgPage, | 57 | component: OrgPage, |
| @@ -109,8 +114,6 @@ function OrgPage() { · 56 unmodified lines | |||
| 109 | const membersResult = useAtomValue(orgMembersAtom); | 114 | const membersResult = useAtomValue(orgMembersAtom); |
| 110 | const rolesResult = useAtomValue(orgRolesAtom); | 115 | const rolesResult = useAtomValue(orgRolesAtom); |
| 111 | const domainsResult = useAtomValue(orgDomainsAtom); | 116 | const domainsResult = useAtomValue(orgDomainsAtom); |
| 112 | const refreshMembers = useAtomRefresh(orgMembersAtom); | ||
| 113 | const refreshDomains = useAtomRefresh(orgDomainsAtom); | ||
| 114 | const doRemove = useAtomSet(removeMember, { mode: "promiseExit" }); | 117 | const doRemove = useAtomSet(removeMember, { mode: "promiseExit" }); |
| 115 | const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" }); | 118 | const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" }); |
| 116 | const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" }); | 119 | const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" }); |
| @@ -130,27 +133,27 @@ function OrgPage() { · 13 unmodified lines | |||
| 130 | }); | 133 | }); |
| 131 | 134 | ||
| 132 | const handleRemove = async (membershipId: string, name: string) => { | 135 | const handleRemove = async (membershipId: string, name: string) => { |
| 133 | const exit = await doRemove({ path: { membershipId } }); | 136 | const exit = await doRemove({ path: { membershipId }, reactivityKeys: orgMemberWriteKeys }); |
| 134 | if (Exit.isSuccess(exit)) { | 137 | if (Exit.isSuccess(exit)) { |
| 135 | toast.success(`Removed ${name}`); | 138 | toast.success(`Removed ${name}`); |
| 136 | refreshMembers(); | ||
| 137 | } else { | 139 | } else { |
| 138 | toast.error("Failed to remove member"); | 140 | toast.error("Failed to remove member"); |
| 139 | } | 141 | } |
| 140 | }; | 142 | }; |
| 141 | 143 | ||
| 142 | const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => { | 144 | const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => { |
| 143 | const exit = await doUpdateRole({ path: { membershipId }, payload: { roleSlug } }); | 145 | const exit = await doUpdateRole({ |
| 146 | path: { membershipId }, | ||
| 147 | payload: { roleSlug }, | ||
| 148 | reactivityKeys: orgMemberWriteKeys, | ||
| 149 | }); | ||
| 144 | if (Exit.isSuccess(exit)) { | 150 | if (Exit.isSuccess(exit)) { |
| 145 | toast.success(`Role changed to ${roleName}`); | 151 | toast.success(`Role changed to ${roleName}`); |
| 146 | refreshMembers(); | ||
| 147 | } else { | 152 | } else { |
| 148 | toast.error("Failed to change role"); | 153 | toast.error("Failed to change role"); |
| 149 | } | 154 | } |
| 150 | }; | 155 | }; |
| 151 | 156 | ||
| 152 | const refreshAuth = useAtomRefresh(authAtom); | ||
| 153 | |||
| 154 | const handleSaveName = async () => { | 157 | const handleSaveName = async () => { |
| 155 | const trimmed = editName.trim(); | 158 | const trimmed = editName.trim(); |
| 156 | if (!trimmed || trimmed === orgName) { | 159 | if (!trimmed || trimmed === orgName) { |
| @@ -158,10 +161,12 @@ function OrgPage() { · 1 unmodified line | |||
| 158 | return; | 161 | return; |
| 159 | } | 162 | } |
| 160 | setSavingName(true); | 163 | setSavingName(true); |
| 161 | const exit = await doUpdateOrgName({ payload: { name: trimmed } }); | 164 | const exit = await doUpdateOrgName({ |
| 165 | payload: { name: trimmed }, | ||
| 166 | reactivityKeys: orgInfoWriteKeys, | ||
| 167 | }); | ||
| 162 | if (Exit.isSuccess(exit)) { | 168 | if (Exit.isSuccess(exit)) { |
| 163 | toast.success("Organization name updated"); | 169 | toast.success("Organization name updated"); |
| 164 | refreshAuth(); | ||
| 165 | } else { | 170 | } else { |
| 166 | toast.error("Failed to update organization name"); | 171 | toast.error("Failed to update organization name"); |
| 167 | setEditName(orgName); | 172 | setEditName(orgName); |
| @@ -170,17 +175,19 @@ function OrgPage() { · 2 unmodified lines | |||
| 170 | }; | 175 | }; |
| 171 | 176 | ||
| 172 | const handleDeleteDomain = async (domainId: string, domain: string) => { | 177 | const handleDeleteDomain = async (domainId: string, domain: string) => { |
| 173 | const exit = await doDeleteDomain({ path: { domainId } }); | 178 | const exit = await doDeleteDomain({ |
| 179 | path: { domainId }, | ||
| 180 | reactivityKeys: orgDomainWriteKeys, | ||
| 181 | }); | ||
| 174 | if (Exit.isSuccess(exit)) { | 182 | if (Exit.isSuccess(exit)) { |
| 175 | toast.success(`Removed ${domain}`); | 183 | toast.success(`Removed ${domain}`); |
| 176 | refreshDomains(); | ||
| 177 | } else { | 184 | } else { |
| 178 | toast.error("Failed to remove domain"); | 185 | toast.error("Failed to remove domain"); |
| 179 | } | 186 | } |
| 180 | }; | 187 | }; |
| 181 | 188 | ||
| 182 | const handleAddDomain = async () => { | 189 | const handleAddDomain = async () => { |
| 183 | const exit = await doGetVerificationLink({}); | 190 | const exit = await doGetVerificationLink({ reactivityKeys: orgDomainWriteKeys }); |
| 184 | if (Exit.isSuccess(exit)) { | 191 | if (Exit.isSuccess(exit)) { |
| 185 | window.open(exit.value.link, "_blank"); | 192 | window.open(exit.value.link, "_blank"); |
| 186 | } else { | 193 | } else { |
| @@ -473,7 +480,6 @@ function OrgPage() { · 286 unmodified lines | |||
| 473 | <InviteDialog | 480 | <InviteDialog |
| 474 | open={inviteOpen} | 481 | open={inviteOpen} |
| 475 | onOpenChange={setInviteOpen} | 482 | onOpenChange={setInviteOpen} |
| 476 | onInvited={refreshMembers} | ||
| 477 | roles={roles} | 483 | roles={roles} |
| 478 | /> | 484 | /> |
| 479 | </div> | 485 | </div> |
| @@ -580,7 +586,6 @@ function DomainCard({ · 100 unmodified lines | |||
| 580 | function InviteDialog(props: { | 586 | function InviteDialog(props: { |
| 581 | open: boolean; | 587 | open: boolean; |
| 582 | onOpenChange: (v: boolean) => void; | 588 | onOpenChange: (v: boolean) => void; |
| 583 | onInvited: () => void; | ||
| 584 | roles: readonly { slug: string; name: string }[]; | 589 | roles: readonly { slug: string; name: string }[]; |
| 585 | }) { | 590 | }) { |
| 586 | const [state, dispatch] = useReducer(inviteReducer, initialInviteState); | 591 | const [state, dispatch] = useReducer(inviteReducer, initialInviteState); |
| @@ -595,13 +600,13 @@ function InviteDialog(props: { · 8 unmodified lines | |||
| 595 | email: state.email.trim(), | 600 | email: state.email.trim(), |
| 596 | ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}), | 601 | ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}), |
| 597 | }, | 602 | }, |
| 603 | reactivityKeys: orgMemberWriteKeys, | ||
| 598 | }); | 604 | }); |
| 599 | 605 | ||
| 600 | if (Exit.isSuccess(exit)) { | 606 | if (Exit.isSuccess(exit)) { |
| 601 | toast.success(`Invitation sent to ${state.email.trim()}`); | 607 | toast.success(`Invitation sent to ${state.email.trim()}`); |
| 602 | dispatch({ type: "reset" }); | 608 | dispatch({ type: "reset" }); |
| 603 | props.onOpenChange(false); | 609 | props.onOpenChange(false); |
| 604 | props.onInvited(); | ||
| 605 | } else { | 610 | } else { |
| 606 | dispatch({ type: "error", message: "Failed to send invitation" }); | 611 | dispatch({ type: "error", message: "Failed to send invitation" }); |
| 607 | } | 612 | } |
| @@ -1,6 +1,7 @@ | |||
| 1 | import React, { createContext, useContext } from "react"; | 1 | import React, { createContext, useContext } from "react"; |
| 2 | import { Atom } from "@effect-atom/atom"; | 2 | import { Atom } from "@effect-atom/atom"; |
| 3 | import { useAtomValue, Result } from "@effect-atom/atom-react"; | 3 | import { useAtomValue, Result } from "@effect-atom/atom-react"; |
| 4 | import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | ||
| 4 | 5 | ||
| 5 | import { CloudApiClient } from "./client"; | 6 | import { CloudApiClient } from "./client"; |
| 6 | 7 | ||
| @@ -26,10 +27,14 @@ type AuthOrganization = { · 19 unmodified lines | |||
| 26 | 27 | ||
| 27 | export const authAtom = CloudApiClient.query("cloudAuth", "me", { | 28 | export const authAtom = CloudApiClient.query("cloudAuth", "me", { |
| 28 | timeToLive: "5 minutes", | 29 | timeToLive: "5 minutes", |
| 30 | reactivityKeys: [ReactivityKey.auth], | ||
| 29 | }); | 31 | }); |
| 30 | 32 | ||
| 31 | export const organizationsAtom = Atom.refreshOnWindowFocus( | 33 | export const organizationsAtom = Atom.refreshOnWindowFocus( |
| 32 | CloudApiClient.query("cloudAuth", "organizations", { timeToLive: "1 minute" }), | 34 | CloudApiClient.query("cloudAuth", "organizations", { |
| 35 | timeToLive: "1 minute", | ||
| 36 | reactivityKeys: [ReactivityKey.auth], | ||
| 37 | }), | ||
| 33 | ); | 38 | ); |
| 34 | 39 | ||
| 35 | export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization"); | 40 | export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization"); |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { useAtomSet } from "@effect-atom/atom-react"; | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | import { authWriteKeys } from "@executor/react/api/reactivity-keys"; | ||
| 3 | import { Input } from "@executor/react/components/input"; | 4 | import { Input } from "@executor/react/components/input"; |
| 4 | import { Label } from "@executor/react/components/label"; | 5 | import { Label } from "@executor/react/components/label"; |
| 5 | 6 | ||
| @@ -31,7 +32,7 @@ export function useCreateOrganizationForm(options: { · 25 unmodified lines | |||
| 31 | } | 32 | } |
| 32 | setCreating(true); | 33 | setCreating(true); |
| 33 | setError(null); | 34 | setError(null); |
| 34 | const exit = await doCreate({ payload: { name: trimmed } }); | 35 | const exit = await doCreate({ payload: { name: trimmed }, reactivityKeys: authWriteKeys }); |
| 35 | setCreating(false); | 36 | setCreating(false); |
| 36 | if (exit._tag === "Success") { | 37 | if (exit._tag === "Success") { |
| 37 | options.onSuccess(exit.value); | 38 | options.onSuccess(exit.value); |
| @@ -1,14 +1,17 @@ | |||
| 1 | import { Atom } from "@effect-atom/atom"; | 1 | import { Atom } from "@effect-atom/atom"; |
| 2 | import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | ||
| 2 | import { CloudApiClient } from "./client"; | 3 | import { CloudApiClient } from "./client"; |
| 3 | 4 | ||
| 4 | export const orgMembersAtom = Atom.refreshOnWindowFocus( | 5 | export const orgMembersAtom = Atom.refreshOnWindowFocus( |
| 5 | CloudApiClient.query("org", "listMembers", { | 6 | CloudApiClient.query("org", "listMembers", { |
| 6 | timeToLive: "30 seconds", | 7 | timeToLive: "30 seconds", |
| 8 | reactivityKeys: [ReactivityKey.orgMembers], | ||
| 7 | }), | 9 | }), |
| 8 | ); | 10 | ); |
| 9 | 11 | ||
| 10 | export const orgRolesAtom = CloudApiClient.query("org", "listRoles", { | 12 | export const orgRolesAtom = CloudApiClient.query("org", "listRoles", { |
| 11 | timeToLive: "5 minutes", | 13 | timeToLive: "5 minutes", |
| 14 | reactivityKeys: [ReactivityKey.orgMembers], | ||
| 12 | }); | 15 | }); |
| 13 | 16 | ||
| 14 | export const inviteMember = CloudApiClient.mutation("org", "invite"); | 17 | export const inviteMember = CloudApiClient.mutation("org", "invite"); |
| @@ -20,6 +23,7 @@ export const updateMemberRole = CloudApiClient.mutation("org", "updateMemberRole · 5 unmodified lines | |||
| 20 | export const orgDomainsAtom = Atom.refreshOnWindowFocus( | 23 | export const orgDomainsAtom = Atom.refreshOnWindowFocus( |
| 21 | CloudApiClient.query("org", "listDomains", { | 24 | CloudApiClient.query("org", "listDomains", { |
| 22 | timeToLive: "30 seconds", | 25 | timeToLive: "30 seconds", |
| 26 | reactivityKeys: [ReactivityKey.orgDomains], | ||
| 23 | }), | 27 | }), |
| 24 | ); | 28 | ); |
| 25 | 29 | ||
| @@ -1,8 +1,7 @@ | |||
| 1 | import { useAtomRefresh } from "@effect-atom/atom-react"; | ||
| 2 | import { Button } from "@executor/react/components/button"; | 1 | import { Button } from "@executor/react/components/button"; |
| 3 | 2 | ||
| 4 | import { AUTH_PATHS } from "../../auth/api"; | 3 | import { AUTH_PATHS } from "../../auth/api"; |
| 5 | import { authAtom, useAuth } from "../auth"; | 4 | import { useAuth } from "../auth"; |
| 6 | import { | 5 | import { |
| 7 | CreateOrganizationFields, | 6 | CreateOrganizationFields, |
| 8 | useCreateOrganizationForm, | 7 | useCreateOrganizationForm, |
| @@ -10,7 +9,6 @@ import { · 1 unmodified line | |||
| 10 | 9 | ||
| 11 | export const OnboardingPage = () => { | 10 | export const OnboardingPage = () => { |
| 12 | const auth = useAuth(); | 11 | const auth = useAuth(); |
| 13 | const refreshAuth = useAtomRefresh(authAtom); | ||
| 14 | 12 | ||
| 15 | const suggestedName = | 13 | const suggestedName = |
| 16 | auth.status === "authenticated" && | 14 | auth.status === "authenticated" && |
| @@ -21,13 +19,10 @@ export const OnboardingPage = () => { · 4 unmodified lines | |||
| 21 | 19 | ||
| 22 | const form = useCreateOrganizationForm({ | 20 | const form = useCreateOrganizationForm({ |
| 23 | defaultName: suggestedName, | 21 | defaultName: suggestedName, |
| 24 | // On success: the server set a new cookie with the new org; refetch /me | 22 | // The server sets a cookie for the new (or cleared) org on both success and |
| 25 | // so AuthGate routes into Shell. | 23 | // failure. The authWriteKeys passed at the mutation site auto-invalidate |
| 26 | // On failure: the server may have cleared the cookie because the current | 24 | // authAtom so AuthGate re-routes into Shell or LoginPage as appropriate. |
| 27 | // session was too stale to attach the new org. Refetch /me regardless so | 25 | onSuccess: () => {}, |
| 28 | // AuthGate can route to LoginPage if that's the case. | ||
| 29 | onSuccess: () => refreshAuth(), | ||
| 30 | onFailure: () => refreshAuth(), | ||
| 31 | }); | 26 | }); |
| 32 | 27 | ||
| 33 | return ( | 28 | return ( |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | import { useEffect, useRef, useState } from "react"; | 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; | 3 | import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; |
| 4 | import { sourcesAtom } from "@executor/react/api/atoms"; | 4 | import { useSourcesWithPending } from "@executor/react/api/optimistic"; |
| 5 | import { useScope } from "@executor/react/api/scope-context"; | 5 | import { useScope } from "@executor/react/api/scope-context"; |
| 6 | import { Button } from "@executor/react/components/button"; | 6 | import { Button } from "@executor/react/components/button"; |
| 7 | import { Skeleton } from "@executor/react/components/skeleton"; | 7 | import { Skeleton } from "@executor/react/components/skeleton"; |
| @@ -66,7 +66,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 58 unmodified lines | |||
| 66 | 66 | ||
| 67 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { | 67 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { |
| 68 | const scopeId = useScope(); | 68 | const scopeId = useScope(); |
| 69 | const sources = useAtomValue(sourcesAtom(scopeId)); | 69 | const sources = useSourcesWithPending(scopeId); |
| 70 | 70 | ||
| 71 | return Result.match(sources, { | 71 | return Result.match(sources, { |
| 72 | onInitial: () => ( | 72 | onInitial: () => ( |
| @@ -1,7 +1,8 @@ | |||
| 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | import { useCallback, useEffect, useRef, useState } from "react"; | 2 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import { useAtomRefresh, useAtomValue, Result } from "@effect-atom/atom-react"; | 3 | import { useAtomRefresh, Result } from "@effect-atom/atom-react"; |
| 4 | import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms"; | 4 | import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms"; |
| 5 | import { useSourcesWithPending } from "@executor/react/api/optimistic"; | ||
| 5 | import { useScope, useScopeInfo } from "@executor/react/api/scope-context"; | 6 | import { useScope, useScopeInfo } from "@executor/react/api/scope-context"; |
| 6 | import { Button } from "@executor/react/components/button"; | 7 | import { Button } from "@executor/react/components/button"; |
| 7 | import { SourceFavicon } from "@executor/react/components/source-favicon"; | 8 | import { SourceFavicon } from "@executor/react/components/source-favicon"; |
| @@ -224,7 +225,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 216 unmodified lines | |||
| 224 | 225 | ||
| 225 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { | 226 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { |
| 226 | const scopeId = useScope(); | 227 | const scopeId = useScope(); |
| 227 | const sources = useAtomValue(sourcesAtom(scopeId)); | 228 | const sources = useSourcesWithPending(scopeId); |
| 228 | 229 | ||
| 229 | return Result.match(sources, { | 230 | return Result.match(sources, { |
| 230 | onInitial: () => ( | 231 | onInitial: () => ( |
| @@ -37,7 +37,8 @@ · 36 unmodified lines | |||
| 37 | "typecheck": "turbo run typecheck", | 37 | "typecheck": "turbo run typecheck", |
| 38 | "typecheck:slow": "turbo run typecheck:slow", | 38 | "typecheck:slow": "turbo run typecheck:slow", |
| 39 | "ci": "bun run lint && bun run typecheck && bun run test", | 39 | "ci": "bun run lint && bun run typecheck && bun run test", |
| 40 | "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings", | 40 | "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings && bun run lint:reactivity-keys", |
| 41 | "lint:reactivity-keys": "bun run scripts/check-reactivity-keys.ts", | ||
| 41 | "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .", | 42 | "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .", |
| 42 | "format": "oxfmt .", | 43 | "format": "oxfmt .", |
| 43 | "format:check": "oxfmt --check .", | 44 | "format:check": "oxfmt --check .", |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | openOAuthPopup, | 5 | openOAuthPopup, |
| @@ -7,6 +7,8 @@ import { · 1 unmodified line | |||
| 7 | } from "@executor/plugin-oauth2/react"; | 7 | } from "@executor/plugin-oauth2/react"; |
| 8 | 8 | ||
| 9 | import { secretsAtom, setSecret } from "@executor/react/api/atoms"; | 9 | import { secretsAtom, setSecret } from "@executor/react/api/atoms"; |
| 10 | import { usePendingSources } from "@executor/react/api/optimistic"; | ||
| 11 | import { secretWriteKeys, sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | ||
| 10 | import { useScope } from "@executor/react/api/scope-context"; | 12 | import { useScope } from "@executor/react/api/scope-context"; |
| 11 | import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; | 13 | import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; |
| 12 | import { SecretId } from "@executor/sdk"; | 14 | import { SecretId } from "@executor/sdk"; |
| @@ -64,7 +66,6 @@ function InlineCreateSecret(props: { · 51 unmodified lines | |||
| 64 | const [error, setError] = useState<string | null>(null); | 66 | const [error, setError] = useState<string | null>(null); |
| 65 | const scopeId = useScope(); | 67 | const scopeId = useScope(); |
| 66 | const doSet = useAtomSet(setSecret, { mode: "promise" }); | 68 | const doSet = useAtomSet(setSecret, { mode: "promise" }); |
| 67 | const refreshSecrets = useAtomRefresh(secretsAtom(scopeId)); | ||
| 68 | 69 | ||
| 69 | const handleSave = async () => { | 70 | const handleSave = async () => { |
| 70 | if (!secretId.trim() || !secretValue.trim()) return; | 71 | if (!secretId.trim() || !secretValue.trim()) return; |
| @@ -78,8 +79,8 @@ function InlineCreateSecret(props: { · 7 unmodified lines | |||
| 78 | name: secretName.trim() || secretId.trim(), | 79 | name: secretName.trim() || secretId.trim(), |
| 79 | value: secretValue.trim(), | 80 | value: secretValue.trim(), |
| 80 | }, | 81 | }, |
| 82 | reactivityKeys: [...secretWriteKeys], | ||
| 81 | }); | 83 | }); |
| 82 | refreshSecrets(); | ||
| 83 | props.onCreated(secretId.trim()); | 84 | props.onCreated(secretId.trim()); |
| 84 | } catch (e) { | 85 | } catch (e) { |
| 85 | setError(e instanceof Error ? e.message : "Failed to save secret"); | 86 | setError(e instanceof Error ? e.message : "Failed to save secret"); |
| @@ -417,6 +418,7 @@ export default function AddGoogleDiscoverySource(props: { · 331 unmodified lines | |||
| 417 | const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" }); | 418 | const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" }); |
| 418 | const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" }); | 419 | const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" }); |
| 419 | const secrets = useAtomValue(secretsAtom(scopeId)); | 420 | const secrets = useAtomValue(secretsAtom(scopeId)); |
| 421 | const { beginAdd } = usePendingSources(); | ||
| 420 | 422 | ||
| 421 | const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]); | 423 | const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]); |
| 422 | const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { | 424 | const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { |
| @@ -556,11 +558,18 @@ export default function AddGoogleDiscoverySource(props: { · 133 unmodified lines | |||
| 556 | if (!probe) return; | 558 | if (!probe) return; |
| 557 | setAdding(true); | 559 | setAdding(true); |
| 558 | setError(null); | 560 | setError(null); |
| 561 | const displayName = identity.name.trim() || probe.name; | ||
| 562 | const namespace = slugifyNamespace(identity.namespace) || probe.name; | ||
| 563 | const placeholder = beginAdd({ | ||
| 564 | id: namespace, | ||
| 565 | name: displayName, | ||
| 566 | kind: "google-discovery", | ||
| 567 | }); | ||
| 559 | try { | 568 | try { |
| 560 | await doAdd({ | 569 | await doAdd({ |
| 561 | path: { scopeId }, | 570 | path: { scopeId }, |
| 562 | payload: { | 571 | payload: { |
| 563 | name: identity.name.trim() || probe.name, | 572 | name: displayName, |
| 564 | discoveryUrl: discoveryUrl.trim(), | 573 | discoveryUrl: discoveryUrl.trim(), |
| 565 | namespace: slugifyNamespace(identity.namespace) || undefined, | 574 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 566 | auth: | 575 | auth: |
| @@ -568,13 +577,16 @@ export default function AddGoogleDiscoverySource(props: { · 1 unmodified line | |||
| 568 | ? (oauthAuth ?? { kind: "none" as const }) | 577 | ? (oauthAuth ?? { kind: "none" as const }) |
| 569 | : { kind: "none" as const }, | 578 | : { kind: "none" as const }, |
| 570 | }, | 579 | }, |
| 580 | reactivityKeys: [...sourceWriteKeys], | ||
| 571 | }); | 581 | }); |
| 572 | props.onComplete(); | 582 | props.onComplete(); |
| 573 | } catch (e) { | 583 | } catch (e) { |
| 574 | setError(e instanceof Error ? e.message : "Failed to add source"); | 584 | setError(e instanceof Error ? e.message : "Failed to add source"); |
| 575 | setAdding(false); | 585 | setAdding(false); |
| 586 | } finally { | ||
| 587 | placeholder.done(); | ||
| 576 | } | 588 | } |
| 577 | }, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId]); | 589 | }, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId, beginAdd]); |
| 578 | 590 | ||
| 579 | const addDisabled = | 591 | const addDisabled = |
| 580 | !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); | 592 | !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); |
| @@ -1,10 +1,12 @@ | |||
| 1 | import type { ScopeId } from "@executor/sdk"; | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | ||
| 2 | import { GoogleDiscoveryClient } from "./client"; | 3 | import { GoogleDiscoveryClient } from "./client"; |
| 3 | 4 | ||
| 4 | export const googleDiscoverySourceAtom = (scopeId: ScopeId, namespace: string) => | 5 | export const googleDiscoverySourceAtom = (scopeId: ScopeId, namespace: string) => |
| 5 | GoogleDiscoveryClient.query("googleDiscovery", "getSource", { | 6 | GoogleDiscoveryClient.query("googleDiscovery", "getSource", { |
| 6 | path: { scopeId, namespace }, | 7 | path: { scopeId, namespace }, |
| 7 | timeToLive: "15 seconds", | 8 | timeToLive: "15 seconds", |
| 9 | reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], | ||
| 8 | }); | 10 | }); |
| 9 | 11 | ||
| 10 | export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation( | 12 | export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation( |
| @@ -2,6 +2,8 @@ import { useState } from "react"; · 1 unmodified line | |||
| 2 | import { useAtomSet } from "@effect-atom/atom-react"; | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | 3 | ||
| 4 | import { useScope } from "@executor/react/api/scope-context"; | 4 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | ||
| 6 | import { usePendingSources } from "@executor/react/api/optimistic"; | ||
| 5 | import { HeadersList } from "@executor/react/plugins/headers-list"; | 7 | import { HeadersList } from "@executor/react/plugins/headers-list"; |
| 6 | import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; | 8 | import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; |
| 7 | import { | 9 | import { |
| @@ -46,6 +48,7 @@ export default function AddGraphqlSource(props: { · 38 unmodified lines | |||
| 46 | 48 | ||
| 47 | const scopeId = useScope(); | 49 | const scopeId = useScope(); |
| 48 | const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" }); | 50 | const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" }); |
| 51 | const { beginAdd } = usePendingSources(); | ||
| 49 | const secretList = useSecretPickerSecrets(); | 52 | const secretList = useSecretPickerSecrets(); |
| 50 | 53 | ||
| 51 | const headersValid = headers.every((header) => header.name.trim() && header.secretId); | 54 | const headersValid = headers.every((header) => header.name.trim() && header.secretId); |
| @@ -54,31 +57,47 @@ export default function AddGraphqlSource(props: { · 2 unmodified lines | |||
| 54 | const handleAdd = async () => { | 57 | const handleAdd = async () => { |
| 55 | setAdding(true); | 58 | setAdding(true); |
| 56 | setAddError(null); | 59 | setAddError(null); |
| 57 | try { | 60 | const headerMap: Record<string, HeaderValue> = {}; |
| 58 | const headerMap: Record<string, HeaderValue> = {}; | 61 | for (const header of headers) { |
| 59 | for (const header of headers) { | 62 | const name = header.name.trim(); |
| 60 | const name = header.name.trim(); | 63 | if (name && header.secretId) { |
| 61 | if (name && header.secretId) { | 64 | headerMap[name] = { |
| 62 | headerMap[name] = { | 65 | secretId: header.secretId, |
| 63 | secretId: header.secretId, | 66 | ...(header.prefix ? { prefix: header.prefix } : {}), |
| 64 | ...(header.prefix ? { prefix: header.prefix } : {}), | 67 | }; |
| 65 | }; | ||
| 66 | } | ||
| 67 | } | 68 | } |
| 69 | } | ||
| 68 | 70 | ||
| 71 | const trimmedEndpoint = endpoint.trim(); | ||
| 72 | const namespace = | ||
| 73 | slugifyNamespace(identity.namespace) || | ||
| 74 | slugifyNamespace(displayNameFromUrl(trimmedEndpoint) ?? "") || | ||
| 75 | "graphql"; | ||
| 76 | const displayName = | ||
| 77 | identity.name.trim() || displayNameFromUrl(trimmedEndpoint) || namespace; | ||
| 78 | const placeholder = beginAdd({ | ||
| 79 | id: namespace, | ||
| 80 | name: displayName, | ||
| 81 | kind: "graphql", | ||
| 82 | url: trimmedEndpoint || undefined, | ||
| 83 | }); | ||
| 84 | try { | ||
| 69 | await doAdd({ | 85 | await doAdd({ |
| 70 | path: { scopeId }, | 86 | path: { scopeId }, |
| 71 | payload: { | 87 | payload: { |
| 72 | endpoint: endpoint.trim(), | 88 | endpoint: trimmedEndpoint, |
| 73 | name: identity.name.trim() || undefined, | 89 | name: identity.name.trim() || undefined, |
| 74 | namespace: slugifyNamespace(identity.namespace) || undefined, | 90 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 75 | ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), | 91 | ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), |
| 76 | }, | 92 | }, |
| 93 | reactivityKeys: sourceWriteKeys, | ||
| 77 | }); | 94 | }); |
| 78 | props.onComplete(); | 95 | props.onComplete(); |
| 79 | } catch (e) { | 96 | } catch (e) { |
| 80 | setAddError(e instanceof Error ? e.message : "Failed to add source"); | 97 | setAddError(e instanceof Error ? e.message : "Failed to add source"); |
| 81 | setAdding(false); | 98 | setAdding(false); |
| 99 | } finally { | ||
| 100 | placeholder.done(); | ||
| 82 | } | 101 | } |
| 83 | }; | 102 | }; |
| 84 | 103 | ||
| @@ -1,7 +1,8 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; |
| 3 | import { graphqlSourceAtom, updateGraphqlSource } from "./atoms"; | 3 | import { graphqlSourceAtom, updateGraphqlSource } from "./atoms"; |
| 4 | import { useScope } from "@executor/react/api/scope-context"; | 4 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | ||
| 5 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; | 6 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 6 | import { | 7 | import { |
| 7 | headerValueToState, | 8 | headerValueToState, |
| @@ -35,7 +36,6 @@ function EditForm(props: { · 27 unmodified lines | |||
| 35 | }) { | 36 | }) { |
| 36 | const scopeId = useScope(); | 37 | const scopeId = useScope(); |
| 37 | const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promise" }); | 38 | const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promise" }); |
| 38 | const refreshSource = useAtomRefresh(graphqlSourceAtom(scopeId, props.sourceId)); | ||
| 39 | const secretList = useSecretPickerSecrets(); | 39 | const secretList = useSecretPickerSecrets(); |
| 40 | 40 | ||
| 41 | const identity = useSourceIdentity({ | 41 | const identity = useSourceIdentity({ |
| @@ -70,8 +70,8 @@ function EditForm(props: { · 28 unmodified lines | |||
| 70 | endpoint: endpoint.trim() || undefined, | 70 | endpoint: endpoint.trim() || undefined, |
| 71 | headers: headersFromState(headers), | 71 | headers: headersFromState(headers), |
| 72 | }, | 72 | }, |
| 73 | reactivityKeys: sourceWriteKeys, | ||
| 73 | }); | 74 | }); |
| 74 | refreshSource(); | ||
| 75 | setDirty(false); | 75 | setDirty(false); |
| 76 | props.onSave(); | 76 | props.onSave(); |
| 77 | } catch (e) { | 77 | } catch (e) { |
| @@ -1,4 +1,5 @@ | |||
| 1 | import type { ScopeId } from "@executor/sdk"; | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | ||
| 2 | import { GraphqlClient } from "./client"; | 3 | import { GraphqlClient } from "./client"; |
| 3 | 4 | ||
| 4 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| @@ -9,6 +10,7 @@ export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines | |||
| 9 | GraphqlClient.query("graphql", "getSource", { | 10 | GraphqlClient.query("graphql", "getSource", { |
| 10 | path: { scopeId, namespace }, | 11 | path: { scopeId, namespace }, |
| 11 | timeToLive: "15 seconds", | 12 | timeToLive: "15 seconds", |
| 13 | reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], | ||
| 12 | }); | 14 | }); |
| 13 | 15 | ||
| 14 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
| @@ -35,6 +35,8 @@ import { · 34 unmodified lines | |||
| 35 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; | 35 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 36 | 36 | ||
| 37 | type RemoteAuthMode = "none" | "header" | "oauth2"; | 37 | type RemoteAuthMode = "none" | "header" | "oauth2"; |
| 38 | import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | ||
| 39 | import { usePendingSources } from "@executor/react/api/optimistic"; | ||
| 38 | import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms"; | 40 | import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms"; |
| 39 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 41 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 40 | 42 | ||
| @@ -330,6 +332,7 @@ export default function AddMcpSource(props: { · 289 unmodified lines | |||
| 330 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" }); | 332 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" }); |
| 331 | const doAdd = useAtomSet(addMcpSource, { mode: "promise" }); | 333 | const doAdd = useAtomSet(addMcpSource, { mode: "promise" }); |
| 332 | const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" }); | 334 | const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" }); |
| 335 | const { beginAdd } = usePendingSources(); | ||
| 333 | const secretList = useSecretPickerSecrets(); | 336 | const secretList = useSecretPickerSecrets(); |
| 334 | 337 | ||
| 335 | const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none"); | 338 | const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none"); |
| @@ -462,42 +465,51 @@ export default function AddMcpSource(props: { · 126 unmodified lines | |||
| 462 | const handleAddRemote = useCallback(async () => { | 465 | const handleAddRemote = useCallback(async () => { |
| 463 | if (!probe) return; | 466 | if (!probe) return; |
| 464 | dispatch({ type: "add-start" }); | 467 | dispatch({ type: "add-start" }); |
| 465 | try { | 468 | const headerAuth = remoteAuthHeaders[0]; |
| 466 | const headerAuth = remoteAuthHeaders[0]; | 469 | const auth = |
| 467 | const auth = | 470 | remoteAuthMode === "header" && headerAuth?.secretId |
| 468 | remoteAuthMode === "header" && headerAuth?.secretId | 471 | ? { |
| 472 | kind: "header" as const, | ||
| 473 | headerName: headerAuth.name.trim(), | ||
| 474 | secretId: headerAuth.secretId, | ||
| 475 | ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), | ||
| 476 | } | ||
| 477 | : remoteAuthMode === "oauth2" && tokens | ||
| 469 | ? { | 478 | ? { |
| 470 | kind: "header" as const, | 479 | kind: "oauth2" as const, |
| 471 | headerName: headerAuth.name.trim(), | 480 | accessTokenSecretId: tokens.accessTokenSecretId, |
| 472 | secretId: headerAuth.secretId, | 481 | refreshTokenSecretId: tokens.refreshTokenSecretId, |
| 473 | ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), | 482 | tokenType: tokens.tokenType, |
| 483 | expiresAt: tokens.expiresAt, | ||
| 484 | scope: tokens.scope, | ||
| 474 | } | 485 | } |
| 475 | : remoteAuthMode === "oauth2" && tokens | 486 | : { kind: "none" as const }; |
| 476 | ? { | 487 | const headers = Object.fromEntries( |
| 477 | kind: "oauth2" as const, | 488 | remoteHeaders |
| 478 | accessTokenSecretId: tokens.accessTokenSecretId, | 489 | .map((header) => [header.name.trim(), header.value.trim()] as const) |
| 479 | refreshTokenSecretId: tokens.refreshTokenSecretId, | 490 | .filter(([name, value]) => name && value), |
| 480 | tokenType: tokens.tokenType, | 491 | ); |
| 481 | expiresAt: tokens.expiresAt, | 492 | const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; |
| 482 | scope: tokens.scope, | 493 | const slugNamespace = slugifyNamespace(remoteIdentity.namespace); |
| 483 | } | 494 | const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`; |
| 484 | : { kind: "none" as const }; | 495 | const placeholder = beginAdd({ |
| 485 | const headers = Object.fromEntries( | 496 | id: placeholderId, |
| 486 | remoteHeaders | 497 | name: displayName, |
| 487 | .map((header) => [header.name.trim(), header.value.trim()] as const) | 498 | kind: "mcp", |
| 488 | .filter(([name, value]) => name && value), | 499 | url: state.url.trim(), |
| 489 | ); | 500 | }); |
| 490 | 501 | try { | |
| 491 | await doAdd({ | 502 | await doAdd({ |
| 492 | path: { scopeId }, | 503 | path: { scopeId }, |
| 493 | payload: { | 504 | payload: { |
| 494 | transport: "remote" as const, | 505 | transport: "remote" as const, |
| 495 | name: remoteIdentity.name.trim() || probe.serverName || probe.name, | 506 | name: displayName, |
| 496 | namespace: slugifyNamespace(remoteIdentity.namespace) || undefined, | 507 | namespace: slugNamespace || undefined, |
| 497 | endpoint: state.url.trim(), | 508 | endpoint: state.url.trim(), |
| 498 | auth, | 509 | auth, |
| 499 | ...(Object.keys(headers).length > 0 ? { headers } : {}), | 510 | ...(Object.keys(headers).length > 0 ? { headers } : {}), |
| 500 | }, | 511 | }, |
| 512 | reactivityKeys: sourceWriteKeys, | ||
| 501 | }); | 513 | }); |
| 502 | props.onComplete(); | 514 | props.onComplete(); |
| 503 | } catch (e) { | 515 | } catch (e) { |
| @@ -505,6 +517,8 @@ export default function AddMcpSource(props: { · 1 unmodified line | |||
| 505 | type: "add-fail", | 517 | type: "add-fail", |
| 506 | error: e instanceof Error ? e.message : "Failed to add source", | 518 | error: e instanceof Error ? e.message : "Failed to add source", |
| 507 | }); | 519 | }); |
| 520 | } finally { | ||
| 521 | placeholder.done(); | ||
| 508 | } | 522 | } |
| 509 | }, [ | 523 | }, [ |
| 510 | probe, | 524 | probe, |
| @@ -517,6 +531,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines | |||
| 517 | doAdd, | 531 | doAdd, |
| 518 | props, | 532 | props, |
| 519 | scopeId, | 533 | scopeId, |
| 534 | beginAdd, | ||
| 520 | ]); | 535 | ]); |
| 521 | 536 | ||
| 522 | // ---- Stdio actions ---- | 537 | // ---- Stdio actions ---- |
| @@ -549,24 +564,35 @@ export default function AddMcpSource(props: { · 26 unmodified lines | |||
| 549 | if (!cmd) return; | 564 | if (!cmd) return; |
| 550 | setStdioAdding(true); | 565 | setStdioAdding(true); |
| 551 | setStdioError(null); | 566 | setStdioError(null); |
| 567 | const displayName = stdioIdentity.name.trim() || cmd; | ||
| 568 | const slugNamespace = slugifyNamespace(stdioIdentity.namespace); | ||
| 569 | const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`; | ||
| 570 | const placeholder = beginAdd({ | ||
| 571 | id: placeholderId, | ||
| 572 | name: displayName, | ||
| 573 | kind: "mcp", | ||
| 574 | }); | ||
| 552 | try { | 575 | try { |
| 553 | await doAdd({ | 576 | await doAdd({ |
| 554 | path: { scopeId }, | 577 | path: { scopeId }, |
| 555 | payload: { | 578 | payload: { |
| 556 | transport: "stdio" as const, | 579 | transport: "stdio" as const, |
| 557 | name: stdioIdentity.name.trim() || cmd, | 580 | name: displayName, |
| 558 | namespace: slugifyNamespace(stdioIdentity.namespace) || undefined, | 581 | namespace: slugNamespace || undefined, |
| 559 | command: cmd, | 582 | command: cmd, |
| 560 | args: parseStdioArgs(stdioArgs), | 583 | args: parseStdioArgs(stdioArgs), |
| 561 | env: parseStdioEnv(stdioEnv), | 584 | env: parseStdioEnv(stdioEnv), |
| 562 | }, | 585 | }, |
| 586 | reactivityKeys: sourceWriteKeys, | ||
| 563 | }); | 587 | }); |
| 564 | props.onComplete(); | 588 | props.onComplete(); |
| 565 | } catch (e) { | 589 | } catch (e) { |
| 566 | setStdioError(e instanceof Error ? e.message : "Failed to add source"); | 590 | setStdioError(e instanceof Error ? e.message : "Failed to add source"); |
| 567 | setStdioAdding(false); | 591 | setStdioAdding(false); |
| 592 | } finally { | ||
| 593 | placeholder.done(); | ||
| 568 | } | 594 | } |
| 569 | }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]); | 595 | }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]); |
| 570 | 596 | ||
| 571 | // ---- Render ---- | 597 | // ---- Render ---- |
| 572 | 598 | ||
| @@ -1,7 +1,8 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; |
| 3 | import { mcpSourceAtom, updateMcpSource } from "./atoms"; | 3 | import { mcpSourceAtom, updateMcpSource } from "./atoms"; |
| 4 | import { useScope } from "@executor/react/api/scope-context"; | 4 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | ||
| 5 | import { | 6 | import { |
| 6 | SourceIdentityFields, | 7 | SourceIdentityFields, |
| 7 | useSourceIdentity, | 8 | useSourceIdentity, |
| @@ -37,7 +38,6 @@ function RemoteEditForm(props: { · 29 unmodified lines | |||
| 37 | }) { | 38 | }) { |
| 38 | const scopeId = useScope(); | 39 | const scopeId = useScope(); |
| 39 | const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" }); | 40 | const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" }); |
| 40 | const refreshSource = useAtomRefresh(mcpSourceAtom(scopeId, props.sourceId)); | ||
| 41 | 41 | ||
| 42 | const identity = useSourceIdentity({ | 42 | const identity = useSourceIdentity({ |
| 43 | fallbackName: props.initial.name, | 43 | fallbackName: props.initial.name, |
| @@ -90,8 +90,8 @@ function RemoteEditForm(props: { · 46 unmodified lines | |||
| 90 | endpoint: endpoint.trim() || undefined, | 90 | endpoint: endpoint.trim() || undefined, |
| 91 | headers: headersObj, | 91 | headers: headersObj, |
| 92 | }, | 92 | }, |
| 93 | reactivityKeys: sourceWriteKeys, | ||
| 93 | }); | 94 | }); |
| 94 | refreshSource(); | ||
| 95 | setDirty(false); | 95 | setDirty(false); |
| 96 | props.onSave(); | 96 | props.onSave(); |
| 97 | } catch (e) { | 97 | } catch (e) { |
| @@ -1,4 +1,5 @@ | |||
| 1 | import type { ScopeId } from "@executor/sdk"; | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | ||
| 2 | import { McpClient } from "./client"; | 3 | import { McpClient } from "./client"; |
| 3 | 4 | ||
| 4 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| @@ -9,6 +10,7 @@ export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines | |||
| 9 | McpClient.query("mcp", "getSource", { | 10 | McpClient.query("mcp", "getSource", { |
| 10 | path: { scopeId, namespace }, | 11 | path: { scopeId, namespace }, |
| 11 | timeToLive: "15 seconds", | 12 | timeToLive: "15 seconds", |
| 13 | reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], | ||
| 12 | }); | 14 | }); |
| 13 | 15 | ||
| 14 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue, useAtomRefresh } from "@effect-atom/atom-react"; | 2 | import { useAtomSet, useAtomValue } from "@effect-atom/atom-react"; |
| 3 | import { Result } from "@effect-atom/atom-react"; | 3 | import { Result } from "@effect-atom/atom-react"; |
| 4 | import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | ||
| 4 | import { useScope } from "@executor/react/api/scope-context"; | 5 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | import { Button } from "@executor/react/components/button"; | 6 | import { Button } from "@executor/react/components/button"; |
| 6 | import { Input } from "@executor/react/components/input"; | 7 | import { Input } from "@executor/react/components/input"; |
| @@ -139,7 +140,6 @@ function ConfigDialog(props: { · 132 unmodified lines | |||
| 139 | 140 | ||
| 140 | const scopeId = useScope(); | 141 | const scopeId = useScope(); |
| 141 | const doConfigure = useAtomSet(configureOnePassword, { mode: "promise" }); | 142 | const doConfigure = useAtomSet(configureOnePassword, { mode: "promise" }); |
| 142 | const refreshConfig = useAtomRefresh(onepasswordConfigAtom(scopeId)); | ||
| 143 | 143 | ||
| 144 | const reset = () => { | 144 | const reset = () => { |
| 145 | if (!isEdit) { | 145 | if (!isEdit) { |
| @@ -165,9 +165,9 @@ function ConfigDialog(props: { · 19 unmodified lines | |||
| 165 | await doConfigure({ | 165 | await doConfigure({ |
| 166 | path: { scopeId }, | 166 | path: { scopeId }, |
| 167 | payload: { auth, vaultId: vaultId.trim(), name: vaultName.trim() || "1Password" }, | 167 | payload: { auth, vaultId: vaultId.trim(), name: vaultName.trim() || "1Password" }, |
| 168 | reactivityKeys: [ReactivityKey.secrets], | ||
| 168 | }); | 169 | }); |
| 169 | props.onOpenChange(false); | 170 | props.onOpenChange(false); |
| 170 | refreshConfig(); | ||
| 171 | reset(); | 171 | reset(); |
| 172 | } catch (e) { | 172 | } catch (e) { |
| 173 | setError(e instanceof Error ? e.message : "Failed to save configuration"); | 173 | setError(e instanceof Error ? e.message : "Failed to save configuration"); |
| @@ -296,12 +296,10 @@ export default function OnePasswordSettings() { · 122 unmodified lines | |||
| 296 | const scopeId = useScope(); | 296 | const scopeId = useScope(); |
| 297 | const configResult = useAtomValue(onepasswordConfigAtom(scopeId)); | 297 | const configResult = useAtomValue(onepasswordConfigAtom(scopeId)); |
| 298 | const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promise" }); | 298 | const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promise" }); |
| 299 | const refreshConfig = useAtomRefresh(onepasswordConfigAtom(scopeId)); | ||
| 300 | 299 | ||
| 301 | const handleRemove = async () => { | 300 | const handleRemove = async () => { |
| 302 | try { | 301 | try { |
| 303 | await doRemove({ path: { scopeId } }); | 302 | await doRemove({ path: { scopeId }, reactivityKeys: [ReactivityKey.secrets] }); |
| 304 | refreshConfig(); | ||
| 305 | } catch { | 303 | } catch { |
| 306 | /* TODO: toast */ | 304 | /* TODO: toast */ |
| 307 | } | 305 | } |
| @@ -1,6 +1,9 @@ | |||
| 1 | import type { ScopeId } from "@executor/sdk"; | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | ||
| 2 | import { OnePasswordClient } from "./client"; | 3 | import { OnePasswordClient } from "./client"; |
| 3 | 4 | ||
| 5 | export const onepasswordWriteKeys = [ReactivityKey.secrets] as const; | ||
| 6 | |||
| 4 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 5 | // Query atoms | 8 | // Query atoms |
| 6 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| @@ -9,12 +12,14 @@ export const onepasswordConfigAtom = (scopeId: ScopeId) => · 2 unmodified lines | |||
| 9 | OnePasswordClient.query("onepassword", "getConfig", { | 12 | OnePasswordClient.query("onepassword", "getConfig", { |
| 10 | path: { scopeId }, | 13 | path: { scopeId }, |
| 11 | timeToLive: "30 seconds", | 14 | timeToLive: "30 seconds", |
| 15 | reactivityKeys: [ReactivityKey.secrets], | ||
| 12 | }); | 16 | }); |
| 13 | 17 | ||
| 14 | export const onepasswordStatusAtom = (scopeId: ScopeId) => | 18 | export const onepasswordStatusAtom = (scopeId: ScopeId) => |
| 15 | OnePasswordClient.query("onepassword", "status", { | 19 | OnePasswordClient.query("onepassword", "status", { |
| 16 | path: { scopeId }, | 20 | path: { scopeId }, |
| 17 | timeToLive: "15 seconds", | 21 | timeToLive: "15 seconds", |
| 22 | reactivityKeys: [ReactivityKey.secrets], | ||
| 18 | }); | 23 | }); |
| 19 | 24 | ||
| 20 | // --------------------------------------------------------------------------- | 25 | // --------------------------------------------------------------------------- |
| @@ -30,6 +35,7 @@ export const onepasswordVaultsAtom = ( · 9 unmodified lines | |||
| 30 | path: { scopeId }, | 35 | path: { scopeId }, |
| 31 | urlParams: { authKind, account }, | 36 | urlParams: { authKind, account }, |
| 32 | timeToLive: "30 seconds", | 37 | timeToLive: "30 seconds", |
| 38 | reactivityKeys: [ReactivityKey.secrets], | ||
| 33 | }); | 39 | }); |
| 34 | 40 | ||
| 35 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| @@ -6,6 +6,8 @@ import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/r · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { SecretPicker } from "@executor/react/plugins/secret-picker"; | 7 | import { SecretPicker } from "@executor/react/plugins/secret-picker"; |
| 8 | import { useScope } from "@executor/react/api/scope-context"; | 8 | import { useScope } from "@executor/react/api/scope-context"; |
| 9 | import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | ||
| 10 | import { usePendingSources } from "@executor/react/api/optimistic"; | ||
| 9 | import { HeadersList } from "@executor/react/plugins/headers-list"; | 11 | import { HeadersList } from "@executor/react/plugins/headers-list"; |
| 10 | import { | 12 | import { |
| 11 | matchPresetKey, | 13 | matchPresetKey, |
| @@ -171,6 +173,7 @@ export default function AddOpenApiSource(props: { · 159 unmodified lines | |||
| 171 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); | 173 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); |
| 172 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); | 174 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); |
| 173 | const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" }); | 175 | const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" }); |
| 176 | const { beginAdd } = usePendingSources(); | ||
| 174 | const secretList = useSecretPickerSecrets(); | 177 | const secretList = useSecretPickerSecrets(); |
| 175 | 178 | ||
| 176 | // Keep the latest handleAnalyze in a ref so the debounced effect doesn't | 179 | // Keep the latest handleAnalyze in a ref so the debounced effect doesn't |
| @@ -456,6 +459,18 @@ export default function AddOpenApiSource(props: { · 279 unmodified lines | |||
| 456 | const handleAdd = async () => { | 459 | const handleAdd = async () => { |
| 457 | setAdding(true); | 460 | setAdding(true); |
| 458 | setAddError(null); | 461 | setAddError(null); |
| 462 | const namespace = | ||
| 463 | slugifyNamespace(identity.namespace) || | ||
| 464 | (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); | ||
| 465 | const displayName = | ||
| 466 | identity.name.trim() || | ||
| 467 | (preview ? Option.getOrElse(preview.title, () => namespace) : namespace); | ||
| 468 | const placeholder = beginAdd({ | ||
| 469 | id: namespace, | ||
| 470 | name: displayName, | ||
| 471 | kind: "openapi", | ||
| 472 | url: resolvedBaseUrl || undefined, | ||
| 473 | }); | ||
| 459 | try { | 474 | try { |
| 460 | await doAdd({ | 475 | await doAdd({ |
| 461 | path: { scopeId }, | 476 | path: { scopeId }, |
| @@ -467,11 +482,14 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines | |||
| 467 | ...(hasHeaders ? { headers: allHeaders } : {}), | 482 | ...(hasHeaders ? { headers: allHeaders } : {}), |
| 468 | ...(oauth2Auth ? { oauth2: oauth2Auth } : {}), | 483 | ...(oauth2Auth ? { oauth2: oauth2Auth } : {}), |
| 469 | }, | 484 | }, |
| 485 | reactivityKeys: sourceWriteKeys, | ||
| 470 | }); | 486 | }); |
| 471 | props.onComplete(); | 487 | props.onComplete(); |
| 472 | } catch (e) { | 488 | } catch (e) { |
| 473 | setAddError(e instanceof Error ? e.message : "Failed to add source"); | 489 | setAddError(e instanceof Error ? e.message : "Failed to add source"); |
| 474 | setAdding(false); | 490 | setAdding(false); |
| 491 | } finally { | ||
| 492 | placeholder.done(); | ||
| 475 | } | 493 | } |
| 476 | }; | 494 | }; |
| 477 | 495 | ||
| @@ -1,7 +1,8 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; |
| 3 | import { openApiSourceAtom, updateOpenApiSource } from "./atoms"; | 3 | import { openApiSourceAtom, updateOpenApiSource } from "./atoms"; |
| 4 | import { useScope } from "@executor/react/api/scope-context"; | 4 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | ||
| 5 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; | 6 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 6 | import { | 7 | import { |
| 7 | headerValueToState, | 8 | headerValueToState, |
| @@ -35,7 +36,6 @@ function EditForm(props: { · 27 unmodified lines | |||
| 35 | }) { | 36 | }) { |
| 36 | const scopeId = useScope(); | 37 | const scopeId = useScope(); |
| 37 | const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" }); | 38 | const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" }); |
| 38 | const refreshSource = useAtomRefresh(openApiSourceAtom(scopeId, props.sourceId)); | ||
| 39 | const secretList = useSecretPickerSecrets(); | 39 | const secretList = useSecretPickerSecrets(); |
| 40 | 40 | ||
| 41 | const identity = useSourceIdentity({ | 41 | const identity = useSourceIdentity({ |
| @@ -70,8 +70,8 @@ function EditForm(props: { · 28 unmodified lines | |||
| 70 | baseUrl: baseUrl.trim() || undefined, | 70 | baseUrl: baseUrl.trim() || undefined, |
| 71 | headers: headersFromState(headers), | 71 | headers: headersFromState(headers), |
| 72 | }, | 72 | }, |
| 73 | reactivityKeys: sourceWriteKeys, | ||
| 73 | }); | 74 | }); |
| 74 | refreshSource(); | ||
| 75 | setDirty(false); | 75 | setDirty(false); |
| 76 | props.onSave(); | 76 | props.onSave(); |
| 77 | } catch (e) { | 77 | } catch (e) { |
| @@ -1,4 +1,5 @@ | |||
| 1 | import type { ScopeId } from "@executor/sdk"; | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | ||
| 2 | import { OpenApiClient } from "./client"; | 3 | import { OpenApiClient } from "./client"; |
| 3 | 4 | ||
| 4 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| @@ -9,6 +10,7 @@ export const openApiSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines | |||
| 9 | OpenApiClient.query("openapi", "getSource", { | 10 | OpenApiClient.query("openapi", "getSource", { |
| 10 | path: { scopeId, namespace }, | 11 | path: { scopeId, namespace }, |
| 11 | timeToLive: "15 seconds", | 12 | timeToLive: "15 seconds", |
| 13 | reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], | ||
| 12 | }); | 14 | }); |
| 13 | 15 | ||
| 14 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
| @@ -2,6 +2,7 @@ import type { ScopeId, ToolId, SecretId } from "@executor/sdk"; · 1 unmodified line | |||
| 2 | import { Atom } from "@effect-atom/atom-react"; | 2 | import { Atom } from "@effect-atom/atom-react"; |
| 3 | 3 | ||
| 4 | import { ExecutorApiClient } from "./client"; | 4 | import { ExecutorApiClient } from "./client"; |
| 5 | import { ReactivityKey } from "./reactivity-keys"; | ||
| 5 | 6 | ||
| 6 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 7 | // Scope — fetched from the server | 8 | // Scope — fetched from the server |
| @@ -9,6 +10,7 @@ import { ExecutorApiClient } from "./client"; · 1 unmodified line | |||
| 9 | 10 | ||
| 10 | export const scopeAtom = ExecutorApiClient.query("scope", "info", { | 11 | export const scopeAtom = ExecutorApiClient.query("scope", "info", { |
| 11 | timeToLive: "5 minutes", | 12 | timeToLive: "5 minutes", |
| 13 | reactivityKeys: [ReactivityKey.scope], | ||
| 12 | }); | 14 | }); |
| 13 | 15 | ||
| 14 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
| @@ -19,6 +21,7 @@ export const toolsAtom = (scopeId: ScopeId) => · 4 unmodified lines | |||
| 19 | ExecutorApiClient.query("tools", "list", { | 21 | ExecutorApiClient.query("tools", "list", { |
| 20 | path: { scopeId }, | 22 | path: { scopeId }, |
| 21 | timeToLive: "30 seconds", | 23 | timeToLive: "30 seconds", |
| 24 | reactivityKeys: [ReactivityKey.tools], | ||
| 22 | }); | 25 | }); |
| 23 | 26 | ||
| 24 | /** Tools for a specific source */ | 27 | /** Tools for a specific source */ |
| @@ -26,18 +29,21 @@ export const sourceToolsAtom = (sourceId: string, scopeId: ScopeId) => · 1 unmodified line | |||
| 26 | ExecutorApiClient.query("sources", "tools", { | 29 | ExecutorApiClient.query("sources", "tools", { |
| 27 | path: { scopeId, sourceId }, | 30 | path: { scopeId, sourceId }, |
| 28 | timeToLive: "30 seconds", | 31 | timeToLive: "30 seconds", |
| 32 | reactivityKeys: [ReactivityKey.tools], | ||
| 29 | }); | 33 | }); |
| 30 | 34 | ||
| 31 | export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) => | 35 | export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) => |
| 32 | ExecutorApiClient.query("tools", "schema", { | 36 | ExecutorApiClient.query("tools", "schema", { |
| 33 | path: { scopeId, toolId }, | 37 | path: { scopeId, toolId }, |
| 34 | timeToLive: "1 minute", | 38 | timeToLive: "1 minute", |
| 39 | reactivityKeys: [ReactivityKey.tools], | ||
| 35 | }); | 40 | }); |
| 36 | 41 | ||
| 37 | export const sourcesAtom = (scopeId: ScopeId) => | 42 | export const sourcesAtom = (scopeId: ScopeId) => |
| 38 | ExecutorApiClient.query("sources", "list", { | 43 | ExecutorApiClient.query("sources", "list", { |
| 39 | path: { scopeId }, | 44 | path: { scopeId }, |
| 40 | timeToLive: "30 seconds", | 45 | timeToLive: "30 seconds", |
| 46 | reactivityKeys: [ReactivityKey.sources], | ||
| 41 | }); | 47 | }); |
| 42 | 48 | ||
| 43 | /** Single source by id — derived from the sources list */ | 49 | /** Single source by id — derived from the sources list */ |
| @@ -48,16 +54,20 @@ export const secretsAtom = (scopeId: ScopeId) => · 4 unmodified lines | |||
| 48 | ExecutorApiClient.query("secrets", "list", { | 54 | ExecutorApiClient.query("secrets", "list", { |
| 49 | path: { scopeId }, | 55 | path: { scopeId }, |
| 50 | timeToLive: "30 seconds", | 56 | timeToLive: "30 seconds", |
| 57 | reactivityKeys: [ReactivityKey.secrets], | ||
| 51 | }); | 58 | }); |
| 52 | 59 | ||
| 53 | export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => | 60 | export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => |
| 54 | ExecutorApiClient.query("secrets", "status", { | 61 | ExecutorApiClient.query("secrets", "status", { |
| 55 | path: { scopeId, secretId }, | 62 | path: { scopeId, secretId }, |
| 56 | timeToLive: "15 seconds", | 63 | timeToLive: "15 seconds", |
| 64 | reactivityKeys: [ReactivityKey.secrets], | ||
| 57 | }); | 65 | }); |
| 58 | 66 | ||
| 59 | // --------------------------------------------------------------------------- | 67 | // --------------------------------------------------------------------------- |
| 60 | // Mutation atoms | 68 | // Mutation atoms — reactivityKeys must be passed at call site (effect-atom |
| 69 | // does not accept them at definition time). See `reactivity-keys.tsx` for the | ||
| 70 | // canonical key arrays. | ||
| 61 | // --------------------------------------------------------------------------- | 71 | // --------------------------------------------------------------------------- |
| 62 | 72 | ||
| 63 | export const setSecret = ExecutorApiClient.mutation("secrets", "set"); | 73 | export const setSecret = ExecutorApiClient.mutation("secrets", "set"); |
| @@ -0,0 +1,143 @@ | |||
| 1 | import * as React from "react"; | ||
| 2 | import { Atom, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | ||
| 3 | import type { ScopeId } from "@executor/sdk"; | ||
| 4 | |||
| 5 | import { sourcesAtom } from "./atoms"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * Client-only optimistic-update layer. | ||
| 9 | * | ||
| 10 | * Mutations that add a row return only after the server roundtrip. The | ||
| 11 | * `pendingResourceAtom` family lets a UI insert a placeholder *before* the | ||
| 12 | * mutation resolves so the sidebar/list updates instantly. Once the server | ||
| 13 | * returns and reactivity refetches the canonical list, callers should remove | ||
| 14 | * the placeholder (the dedupe in `mergePending` filters it anyway, but | ||
| 15 | * explicit removal keeps the pending set bounded on errors). | ||
| 16 | * | ||
| 17 | * Resource keys (`"sources"`, `"secrets"`, …) match the strings in | ||
| 18 | * `ReactivityKey` but are intentionally not coupled — pending state is purely | ||
| 19 | * client-side. | ||
| 20 | */ | ||
| 21 | |||
| 22 | export const PendingResource = { | ||
| 23 | sources: "sources", | ||
| 24 | } as const; | ||
| 25 | |||
| 26 | export interface PendingEntry<T> { | ||
| 27 | readonly id: string; | ||
| 28 | readonly value: T; | ||
| 29 | } | ||
| 30 | |||
| 31 | const pendingFamily = Atom.family((_resource: string) => | ||
| 32 | Atom.make<ReadonlyArray<PendingEntry<unknown>>>([]), | ||
| 33 | ); | ||
| 34 | |||
| 35 | const atomFor = <T,>(resource: string) => | ||
| 36 | pendingFamily(resource) as ReturnType<typeof pendingFamily> & { | ||
| 37 | readonly __t?: T; | ||
| 38 | }; | ||
| 39 | |||
| 40 | export const usePendingResource = <T,>(resource: string) => { | ||
| 41 | const atom = atomFor<T>(resource); | ||
| 42 | const pending = useAtomValue(atom) as ReadonlyArray<PendingEntry<T>>; | ||
| 43 | const setPending = useAtomSet(atom); | ||
| 44 | |||
| 45 | const add = React.useCallback( | ||
| 46 | (entry: PendingEntry<T>) => | ||
| 47 | setPending((prev) => [...(prev as ReadonlyArray<PendingEntry<T>>), entry]), | ||
| 48 | [setPending], | ||
| 49 | ); | ||
| 50 | |||
| 51 | const remove = React.useCallback( | ||
| 52 | (id: string) => | ||
| 53 | setPending((prev) => | ||
| 54 | (prev as ReadonlyArray<PendingEntry<T>>).filter((p) => p.id !== id), | ||
| 55 | ), | ||
| 56 | [setPending], | ||
| 57 | ); | ||
| 58 | |||
| 59 | const clear = React.useCallback(() => setPending([]), [setPending]); | ||
| 60 | |||
| 61 | return { pending, add, remove, clear }; | ||
| 62 | }; | ||
| 63 | |||
| 64 | /** | ||
| 65 | * Merges pending entries with a server-loaded list. Pending entries whose id | ||
| 66 | * already exists on the server are dropped so we never double-render a row | ||
| 67 | * once the canonical version arrives. | ||
| 68 | */ | ||
| 69 | export const mergePending = <T, R>( | ||
| 70 | pending: ReadonlyArray<PendingEntry<T>>, | ||
| 71 | server: ReadonlyArray<R>, | ||
| 72 | serverId: (row: R) => string, | ||
| 73 | fromPending: (entry: PendingEntry<T>) => R, | ||
| 74 | ): ReadonlyArray<R> => { | ||
| 75 | if (pending.length === 0) return server; | ||
| 76 | const seen = new Set(server.map(serverId)); | ||
| 77 | const extras = pending.filter((p) => !seen.has(p.id)).map(fromPending); | ||
| 78 | if (extras.length === 0) return server; | ||
| 79 | return [...extras, ...server]; | ||
| 80 | }; | ||
| 81 | |||
| 82 | // --------------------------------------------------------------------------- | ||
| 83 | // Sources — convenience wrappers used by sidebar/list views and add forms. | ||
| 84 | // Pending entries are global (not per-scope) because the user only sees one | ||
| 85 | // scope at a time; this keeps the add-form code free of scope plumbing. | ||
| 86 | // --------------------------------------------------------------------------- | ||
| 87 | |||
| 88 | export interface PendingSource { | ||
| 89 | readonly name: string; | ||
| 90 | readonly kind: string; | ||
| 91 | readonly url?: string; | ||
| 92 | } | ||
| 93 | |||
| 94 | /** | ||
| 95 | * Sidebar/list helper. Reads `sourcesAtom(scopeId)` and merges any pending | ||
| 96 | * placeholder rows on top so the UI updates instantly when an add form pushes | ||
| 97 | * a placeholder. | ||
| 98 | */ | ||
| 99 | export const useSourcesWithPending = (scopeId: ScopeId) => { | ||
| 100 | const result = useAtomValue(sourcesAtom(scopeId)); | ||
| 101 | const { pending } = usePendingResource<PendingSource>(PendingResource.sources); | ||
| 102 | return React.useMemo( | ||
| 103 | () => | ||
| 104 | Result.map(result, (sources) => | ||
| 105 | mergePending( | ||
| 106 | pending, | ||
| 107 | sources, | ||
| 108 | (s) => s.id, | ||
| 109 | (p) => ({ | ||
| 110 | id: p.id, | ||
| 111 | name: p.value.name, | ||
| 112 | kind: p.value.kind, | ||
| 113 | url: p.value.url, | ||
| 114 | // The placeholder cannot be removed/refreshed/edited until the | ||
| 115 | // server confirms it, so disable those affordances. | ||
| 116 | canRemove: false, | ||
| 117 | canRefresh: false, | ||
| 118 | canEdit: false, | ||
| 119 | }), | ||
| 120 | ), | ||
| 121 | ), | ||
| 122 | [result, pending], | ||
| 123 | ); | ||
| 124 | }; | ||
| 125 | |||
| 126 | /** | ||
| 127 | * Hook for an add form. Returns helpers to push and clear a pending source | ||
| 128 | * placeholder. On submit: | ||
| 129 | * const placeholder = beginAdd({ id: namespace, name, kind }); | ||
| 130 | * try { await doAdd(...); } finally { placeholder.done(); } | ||
| 131 | */ | ||
| 132 | export const usePendingSources = () => { | ||
| 133 | const { add, remove } = usePendingResource<PendingSource>(PendingResource.sources); | ||
| 134 | return React.useMemo( | ||
| 135 | () => ({ | ||
| 136 | beginAdd: (entry: { id: string } & PendingSource) => { | ||
| 137 | add({ id: entry.id, value: { name: entry.name, kind: entry.kind, url: entry.url } }); | ||
| 138 | return { done: () => remove(entry.id) }; | ||
| 139 | }, | ||
| 140 | }), | ||
| 141 | [add, remove], | ||
| 142 | ); | ||
| 143 | }; | ||
| @@ -0,0 +1,57 @@ | |||
| 1 | /** | ||
| 2 | * Canonical reactivity keys for query/mutation invalidation. | ||
| 3 | * | ||
| 4 | * effect-atom's `Reactivity` service refreshes any query whose `reactivityKeys` | ||
| 5 | * overlap with a completed mutation's `reactivityKeys`. The Reactivity instance | ||
| 6 | * is shared across the global Atom registry, so keys interop across plugin | ||
| 7 | * clients (`McpClient`, `OpenApiClient`, `ExecutorApiClient`, …). | ||
| 8 | * | ||
| 9 | * Conventions: | ||
| 10 | * - Every query that reads a server resource sets `reactivityKeys` at the | ||
| 11 | * query atom's definition site. | ||
| 12 | * - Every mutation passes `reactivityKeys` at the call site (mutations don't | ||
| 13 | * accept the option at definition time — see effect-atom AtomHttpApi). | ||
| 14 | * - Use the constants below; do not invent ad-hoc string keys at call sites. | ||
| 15 | * | ||
| 16 | * Per-scope precision is intentionally dropped: a mutation in one scope | ||
| 17 | * invalidating another scope's queries is harmless (users see one scope at a | ||
| 18 | * time) and keeps the convention ergonomic. | ||
| 19 | */ | ||
| 20 | export const ReactivityKey = { | ||
| 21 | sources: "sources", | ||
| 22 | tools: "tools", | ||
| 23 | secrets: "secrets", | ||
| 24 | scope: "scope", | ||
| 25 | // cloud-only resources | ||
| 26 | orgMembers: "org:members", | ||
| 27 | orgDomains: "org:domains", | ||
| 28 | orgInfo: "org:info", | ||
| 29 | auth: "auth", | ||
| 30 | } as const; | ||
| 31 | |||
| 32 | /** Mutations that add/remove/refresh a source also affect tool listings. */ | ||
| 33 | export const sourceWriteKeys = [ReactivityKey.sources, ReactivityKey.tools] as const; | ||
| 34 | |||
| 35 | /** Mutations that mint or revoke secrets. */ | ||
| 36 | export const secretWriteKeys = [ReactivityKey.secrets] as const; | ||
| 37 | |||
| 38 | /** Mutations that change scope membership/info. */ | ||
| 39 | export const scopeWriteKeys = [ReactivityKey.scope] as const; | ||
| 40 | |||
| 41 | /** Cloud-only: org membership mutations. */ | ||
| 42 | export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const; | ||
| 43 | |||
| 44 | /** Cloud-only: org domain mutations. */ | ||
| 45 | export const orgDomainWriteKeys = [ReactivityKey.orgDomains] as const; | ||
| 46 | |||
| 47 | /** Cloud-only: org info mutations (name, etc.) — also touches scope/auth. */ | ||
| 48 | export const orgInfoWriteKeys = [ReactivityKey.orgInfo, ReactivityKey.auth] as const; | ||
| 49 | |||
| 50 | /** Cloud-only: auth mutations (org switch/create) — invalidate everything user-visible. */ | ||
| 51 | export const authWriteKeys = [ | ||
| 52 | ReactivityKey.auth, | ||
| 53 | ReactivityKey.orgInfo, | ||
| 54 | ReactivityKey.orgMembers, | ||
| 55 | ReactivityKey.orgDomains, | ||
| 56 | ReactivityKey.scope, | ||
| 57 | ] as const; | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | import { useState, Suspense } from "react"; | 1 | import { useState, Suspense } from "react"; |
| 2 | import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; |
| 3 | import { secretsAtom, setSecret, removeSecret } from "../api/atoms"; | 3 | import { secretsAtom, setSecret, removeSecret } from "../api/atoms"; |
| 4 | import { secretWriteKeys } from "../api/reactivity-keys"; | ||
| 4 | import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin"; | 5 | import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin"; |
| 5 | import { SecretId } from "@executor/sdk"; | 6 | import { SecretId } from "@executor/sdk"; |
| 6 | import { useScope } from "../hooks/use-scope"; | 7 | import { useScope } from "../hooks/use-scope"; |
| @@ -72,7 +73,6 @@ function AddSecretDialog(props: { · 65 unmodified lines | |||
| 72 | 73 | ||
| 73 | const scopeId = useScope(); | 74 | const scopeId = useScope(); |
| 74 | const doSet = useAtomSet(setSecret, { mode: "promise" }); | 75 | const doSet = useAtomSet(setSecret, { mode: "promise" }); |
| 75 | const refresh = useAtomRefresh(secretsAtom(scopeId)); | ||
| 76 | 76 | ||
| 77 | const reset = () => { | 77 | const reset = () => { |
| 78 | setId(""); | 78 | setId(""); |
| @@ -96,10 +96,10 @@ function AddSecretDialog(props: { · 17 unmodified lines | |||
| 96 | value: value.trim(), | 96 | value: value.trim(), |
| 97 | provider: provider === "auto" ? undefined : provider, | 97 | provider: provider === "auto" ? undefined : provider, |
| 98 | }, | 98 | }, |
| 99 | reactivityKeys: secretWriteKeys, | ||
| 99 | }); | 100 | }); |
| 100 | reset(); | 101 | reset(); |
| 101 | props.onOpenChange(false); | 102 | props.onOpenChange(false); |
| 102 | refresh(); | ||
| 103 | } catch (e) { | 103 | } catch (e) { |
| 104 | setError(e instanceof Error ? e.message : "Failed to save secret"); | 104 | setError(e instanceof Error ? e.message : "Failed to save secret"); |
| 105 | setSaving(false); | 105 | setSaving(false); |
| @@ -292,7 +292,6 @@ export function SecretsPage(props: { · 186 unmodified lines | |||
| 292 | const scopeId = useScope(); | 292 | const scopeId = useScope(); |
| 293 | const secrets = useAtomValue(secretsAtom(scopeId)); | 293 | const secrets = useAtomValue(secretsAtom(scopeId)); |
| 294 | const doRemove = useAtomSet(removeSecret, { mode: "promise" }); | 294 | const doRemove = useAtomSet(removeSecret, { mode: "promise" }); |
| 295 | const refresh = useAtomRefresh(secretsAtom(scopeId)); | ||
| 296 | 295 | ||
| 297 | const handleRemove = async (secretId: string) => { | 296 | const handleRemove = async (secretId: string) => { |
| 298 | try { | 297 | try { |
| @@ -301,8 +300,8 @@ export function SecretsPage(props: { · 2 unmodified lines | |||
| 301 | scopeId, | 300 | scopeId, |
| 302 | secretId: SecretId.make(secretId), | 301 | secretId: SecretId.make(secretId), |
| 303 | }, | 302 | }, |
| 303 | reactivityKeys: secretWriteKeys, | ||
| 304 | }); | 304 | }); |
| 305 | refresh(); | ||
| 306 | } catch { | 305 | } catch { |
| 307 | // TODO: toast | 306 | // TODO: toast |
| 308 | } | 307 | } |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | removeSource, | 8 | removeSource, |
| 9 | refreshSource, | 9 | refreshSource, |
| 10 | } from "../api/atoms"; | 10 | } from "../api/atoms"; |
| 11 | import { sourceWriteKeys } from "../api/reactivity-keys"; | ||
| 11 | import { ToolTree } from "../components/tool-tree"; | 12 | import { ToolTree } from "../components/tool-tree"; |
| 12 | import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; | 13 | import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; |
| 13 | import type { ToolSummary } from "../components/tool-tree"; | 14 | import type { ToolSummary } from "../components/tool-tree"; |
| @@ -81,8 +82,8 @@ export function SourceDetailPage(props: { · 67 unmodified lines | |||
| 81 | try { | 82 | try { |
| 82 | await doRemove({ | 83 | await doRemove({ |
| 83 | path: { scopeId, sourceId: namespace }, | 84 | path: { scopeId, sourceId: namespace }, |
| 85 | reactivityKeys: sourceWriteKeys, | ||
| 84 | }); | 86 | }); |
| 85 | refreshSources(); | ||
| 86 | void navigate({ to: "/" }); | 87 | void navigate({ to: "/" }); |
| 87 | } catch { | 88 | } catch { |
| 88 | setDeleting(false); | 89 | setDeleting(false); |
| @@ -95,9 +96,8 @@ export function SourceDetailPage(props: { · 6 unmodified lines | |||
| 95 | try { | 96 | try { |
| 96 | await doRefresh({ | 97 | await doRefresh({ |
| 97 | path: { scopeId, sourceId: namespace }, | 98 | path: { scopeId, sourceId: namespace }, |
| 99 | reactivityKeys: sourceWriteKeys, | ||
| 98 | }); | 100 | }); |
| 99 | refreshTools(); | ||
| 100 | refreshSources(); | ||
| 101 | } finally { | 101 | } finally { |
| 102 | setRefreshing(false); | 102 | setRefreshing(false); |
| 103 | } | 103 | } |
| @@ -105,8 +105,6 @@ export function SourceDetailPage(props: { · 1 unmodified line | |||
| 105 | 105 | ||
| 106 | const handleEditSave = () => { | 106 | const handleEditSave = () => { |
| 107 | setEditing(false); | 107 | setEditing(false); |
| 108 | refreshSources(); | ||
| 109 | refreshTools(); | ||
| 110 | }; | 108 | }; |
| 111 | 109 | ||
| 112 | return ( | 110 | return ( |
| @@ -1,9 +1,6 @@ | |||
| 1 | import { Suspense } from "react"; | 1 | import { Suspense } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import type { SourcePlugin } from "../plugins/source-plugin"; | 3 | import type { SourcePlugin } from "../plugins/source-plugin"; |
| 4 | import { useAtomRefresh } from "@effect-atom/atom-react"; | ||
| 5 | import { sourcesAtom } from "../api/atoms"; | ||
| 6 | import { useScope } from "../hooks/use-scope"; | ||
| 7 | 4 | ||
| 8 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| 9 | // Page | 6 | // Page |
| @@ -17,8 +14,6 @@ export function SourcesAddPage(props: { · 7 unmodified lines | |||
| 17 | sourcePlugins: readonly SourcePlugin[]; | 14 | sourcePlugins: readonly SourcePlugin[]; |
| 18 | }) { | 15 | }) { |
| 19 | const { pluginKey, url, preset, namespace, sourcePlugins } = props; | 16 | const { pluginKey, url, preset, namespace, sourcePlugins } = props; |
| 20 | const scopeId = useScope(); | ||
| 21 | const refreshSources = useAtomRefresh(sourcesAtom(scopeId)); | ||
| 22 | const navigate = useNavigate(); | 17 | const navigate = useNavigate(); |
| 23 | 18 | ||
| 24 | const plugin = sourcePlugins.find((p) => p.key === pluginKey); | 19 | const plugin = sourcePlugins.find((p) => p.key === pluginKey); |
| @@ -57,7 +52,6 @@ export function SourcesAddPage(props: { · 32 unmodified lines | |||
| 57 | initialPreset={preset} | 52 | initialPreset={preset} |
| 58 | initialNamespace={namespace} | 53 | initialNamespace={namespace} |
| 59 | onComplete={() => { | 54 | onComplete={() => { |
| 60 | refreshSources(); | ||
| 61 | void navigate({ to: "/" }); | 55 | void navigate({ to: "/" }); |
| 62 | }} | 56 | }} |
| 63 | onCancel={() => { | 57 | onCancel={() => { |
| @@ -1,7 +1,8 @@ | |||
| 1 | import { useState, useCallback, useMemo } from "react"; | 1 | import { useState, useCallback, useMemo } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { Result, useAtomValue, useAtomSet } from "@effect-atom/atom-react"; | 3 | import { Result, useAtomSet } from "@effect-atom/atom-react"; |
| 4 | import { sourcesAtom, detectSource } from "../api/atoms"; | 4 | import { detectSource } from "../api/atoms"; |
| 5 | import { useSourcesWithPending } from "../api/optimistic"; | ||
| 5 | import { useScope } from "../hooks/use-scope"; | 6 | import { useScope } from "../hooks/use-scope"; |
| 6 | import type { SourcePlugin, SourcePreset } from "../plugins/source-plugin"; | 7 | import type { SourcePlugin, SourcePreset } from "../plugins/source-plugin"; |
| 7 | import { McpInstallCard } from "../components/mcp-install-card"; | 8 | import { McpInstallCard } from "../components/mcp-install-card"; |
| @@ -41,7 +42,7 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 33 unmodified lines | |||
| 41 | const [error, setError] = useState<string | null>(null); | 42 | const [error, setError] = useState<string | null>(null); |
| 42 | 43 | ||
| 43 | const scopeId = useScope(); | 44 | const scopeId = useScope(); |
| 44 | const sources = useAtomValue(sourcesAtom(scopeId)); | 45 | const sources = useSourcesWithPending(scopeId); |
| 45 | const doDetect = useAtomSet(detectSource, { mode: "promise" }); | 46 | const doDetect = useAtomSet(detectSource, { mode: "promise" }); |
| 46 | const navigate = useNavigate(); | 47 | const navigate = useNavigate(); |
| 47 | 48 | ||
| @@ -1,7 +1,8 @@ | |||
| 1 | import { useId, useState } from "react"; | 1 | import { useId, useState } from "react"; |
| 2 | import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react"; | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | 3 | ||
| 4 | import { secretsAtom, setSecret, resolveSecret } from "../api/atoms"; | 4 | import { setSecret, resolveSecret } from "../api/atoms"; |
| 5 | import { secretWriteKeys } from "../api/reactivity-keys"; | ||
| 5 | import { useScope } from "../api/scope-context"; | 6 | import { useScope } from "../api/scope-context"; |
| 6 | import { Button } from "../components/button"; | 7 | import { Button } from "../components/button"; |
| 7 | import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field"; | 8 | import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field"; |
| @@ -73,7 +74,6 @@ function InlineCreateSecret(props: { · 65 unmodified lines | |||
| 73 | const [error, setError] = useState<string | null>(null); | 74 | const [error, setError] = useState<string | null>(null); |
| 74 | const scopeId = useScope(); | 75 | const scopeId = useScope(); |
| 75 | const doSet = useAtomSet(setSecret, { mode: "promise" }); | 76 | const doSet = useAtomSet(setSecret, { mode: "promise" }); |
| 76 | const refreshSecrets = useAtomRefresh(secretsAtom(scopeId)); | ||
| 77 | const secretIdInputId = useId(); | 77 | const secretIdInputId = useId(); |
| 78 | const secretNameInputId = useId(); | 78 | const secretNameInputId = useId(); |
| 79 | const secretValueInputId = useId(); | 79 | const secretValueInputId = useId(); |
| @@ -90,8 +90,8 @@ function InlineCreateSecret(props: { · 10 unmodified lines | |||
| 90 | name: `${secretName.trim() || secretId.trim()} (Auth header: ${props.headerName})`, | 90 | name: `${secretName.trim() || secretId.trim()} (Auth header: ${props.headerName})`, |
| 91 | value: secretValue.trim(), | 91 | value: secretValue.trim(), |
| 92 | }, | 92 | }, |
| 93 | reactivityKeys: secretWriteKeys, | ||
| 93 | }); | 94 | }); |
| 94 | refreshSecrets(); | ||
| 95 | props.onCreated(secretId.trim()); | 95 | props.onCreated(secretId.trim()); |
| 96 | } catch (e) { | 96 | } catch (e) { |
| 97 | setError(e instanceof Error ? e.message : "Failed to save secret"); | 97 | setError(e instanceof Error ? e.message : "Failed to save secret"); |
| @@ -0,0 +1,124 @@ | |||
| 1 | #!/usr/bin/env bun | ||
| 2 | // Fail CI when a frontend file calls a mutation via `useAtomSet(<atom>, { mode: "promise" })` | ||
| 3 | // without ever passing `reactivityKeys` in the corresponding call args. | ||
| 4 | // | ||
| 5 | // The check is intentionally coarse: per-file we count `useAtomSet(...)` sites | ||
| 6 | // and `reactivityKeys` mentions. A file with N mutation sites must mention | ||
| 7 | // `reactivityKeys` at least N times. Read-only mutations (probes, previews, | ||
| 8 | // auth-flow starts) are allow-listed so the convention can be locked in | ||
| 9 | // without false positives. | ||
| 10 | // | ||
| 11 | // Run: `bun run scripts/check-reactivity-keys.ts` | ||
| 12 | // Exits 1 with a punch list when violations exist. | ||
| 13 | |||
| 14 | import { Glob } from "bun"; | ||
| 15 | |||
| 16 | const ROOTS = ["packages", "apps"]; | ||
| 17 | |||
| 18 | // Mutations that intentionally do not invalidate any cached query (probes, | ||
| 19 | // previews, OAuth-flow kickoffs that just open a window). | ||
| 20 | const READ_ONLY_MUTATIONS = new Set<string>([ | ||
| 21 | "probeMcpEndpoint", | ||
| 22 | "startMcpOAuth", | ||
| 23 | "probeGoogleDiscovery", | ||
| 24 | "startGoogleDiscoveryOAuth", | ||
| 25 | "previewOpenApiSpec", | ||
| 26 | "startOpenApiOAuth", | ||
| 27 | "resolveSecret", | ||
| 28 | "detectSource", | ||
| 29 | "getDomainVerificationLink", | ||
| 30 | ]); | ||
| 31 | |||
| 32 | interface Violation { | ||
| 33 | readonly file: string; | ||
| 34 | readonly line: number; | ||
| 35 | readonly mutationVar: string; | ||
| 36 | } | ||
| 37 | |||
| 38 | const violations: Violation[] = []; | ||
| 39 | |||
| 40 | const useAtomSetRegex = /useAtomSet\(\s*([A-Za-z_][\w]*)\s*,\s*\{\s*mode:\s*"promise"\s*\}\s*\)/g; | ||
| 41 | |||
| 42 | for (const root of ROOTS) { | ||
| 43 | const glob = new Glob(`${root}/**/*.{ts,tsx}`); | ||
| 44 | for await (const path of glob.scan({ cwd: import.meta.dir + "/.." })) { | ||
| 45 | if (path.includes("node_modules") || path.includes(".test.") || path.endsWith(".d.ts")) continue; | ||
| 46 | const file = Bun.file(`${import.meta.dir}/../${path}`); | ||
| 47 | const text = await file.text(); | ||
| 48 | if (!text.includes("useAtomSet")) continue; | ||
| 49 | |||
| 50 | const lines = text.split("\n"); | ||
| 51 | let cursor = 0; | ||
| 52 | for (const line of lines) { | ||
| 53 | cursor++; | ||
| 54 | useAtomSetRegex.lastIndex = 0; | ||
| 55 | const match = useAtomSetRegex.exec(line); | ||
| 56 | if (!match) continue; | ||
| 57 | const mutationVar = match[1] ?? "<unknown>"; | ||
| 58 | // The variable being declared from useAtomSet is what the file later awaits. | ||
| 59 | // Find the binding name to look up the await call. | ||
| 60 | const bindingMatch = line.match(/const\s+(\w+)\s*=\s*useAtomSet/); | ||
| 61 | const binding = bindingMatch?.[1]; | ||
| 62 | |||
| 63 | if (READ_ONLY_MUTATIONS.has(mutationVar)) continue; | ||
| 64 | if (!binding) continue; | ||
| 65 | |||
| 66 | // Look for `await <binding>(...)` calls in the rest of the file and | ||
| 67 | // check whether the *args object* contains `reactivityKeys`. We scan | ||
| 68 | // line-by-line and grow a brace-aware window once we see the call. | ||
| 69 | const callRegex = new RegExp(`await\\s+${binding}\\s*\\(`); | ||
| 70 | let i = cursor; | ||
| 71 | while (i < lines.length) { | ||
| 72 | const l = lines[i] ?? ""; | ||
| 73 | if (callRegex.test(l)) { | ||
| 74 | // Extract the args block from the opening paren until balanced close. | ||
| 75 | let depth = 0; | ||
| 76 | let started = false; | ||
| 77 | let argText = ""; | ||
| 78 | let j = i; | ||
| 79 | while (j < lines.length) { | ||
| 80 | const cur = lines[j] ?? ""; | ||
| 81 | for (const ch of cur) { | ||
| 82 | if (ch === "(") { | ||
| 83 | depth++; | ||
| 84 | started = true; | ||
| 85 | continue; | ||
| 86 | } | ||
| 87 | if (started && ch === ")") { | ||
| 88 | depth--; | ||
| 89 | if (depth === 0) break; | ||
| 90 | } | ||
| 91 | if (started) argText += ch; | ||
| 92 | } | ||
| 93 | if (started && depth === 0) break; | ||
| 94 | j++; | ||
| 95 | if (j - i > 60) break; // bail on runaway | ||
| 96 | } | ||
| 97 | if (!argText.includes("reactivityKeys")) { | ||
| 98 | violations.push({ file: path, line: i + 1, mutationVar }); | ||
| 99 | } | ||
| 100 | break; | ||
| 101 | } | ||
| 102 | i++; | ||
| 103 | if (i - cursor > 80) break; // give up on this site if the await is far away | ||
| 104 | } | ||
| 105 | } | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | if (violations.length === 0) { | ||
| 110 | console.log("✓ reactivityKeys check passed"); | ||
| 111 | process.exit(0); | ||
| 112 | } | ||
| 113 | |||
| 114 | console.error(`✗ reactivityKeys check failed — ${violations.length} mutation call(s) missing reactivityKeys:\n`); | ||
| 115 | for (const v of violations) { | ||
| 116 | console.error(` ${v.file}:${v.line} — ${v.mutationVar}`); | ||
| 117 | } | ||
| 118 | console.error( | ||
| 119 | `\nEvery write mutation must pass reactivityKeys at the call site.\n` + | ||
| 120 | `See packages/react/src/api/reactivity-keys.tsx for canonical key arrays.\n` + | ||
| 121 | `If this mutation truly is read-only (probe/preview/OAuth-start), add it to\n` + | ||
| 122 | `the READ_ONLY_MUTATIONS allowlist in scripts/check-reactivity-keys.ts.`, | ||
| 123 | ); | ||
| 124 | process.exit(1); | ||