From 21a45116cd4825aad91ee6ca6447a5d328973d58 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 01:08:05 -0700 Subject: [PATCH] Add activeWriteScopeId to scope.info, expose useActiveWriteScopeId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope handler now returns three things: the active display scope (`id`/`name`/`dir`), a pre-computed `activeWriteScopeId` (the default source-definition write target — `org` in global, `workspace` in workspace contexts), and the full innermost-first stack for storage-target selectors. The active write scope is computed by skipping personal scopes (those whose id is prefixed `user_org_` or `user_workspace_`) and picking the first non-personal scope from the inner end. That collapses to `org` in the global stack `[user_org, org]` and to `workspace` in the workspace stack `[user_workspace, workspace, user_org, org]`. Client side, expose `useActiveWriteScopeId()` for default writes; keep `useScope()` as an alias so plugin and existing pages don't churn. Update the cloud shell + every page in `packages/react/src/pages/` to call the explicit hook so intent is clear. --- .../src/services/scope-info.node.test.ts | 61 +++++++++++++++++ apps/cloud/src/web/shell.tsx | 4 +- packages/core/api/src/handlers/scope.ts | 56 ++++++++++++--- packages/core/api/src/scope/api.ts | 15 ++++ packages/react/src/api/scope-context.tsx | 68 +++++++++++++++++-- .../react/src/components/command-palette.tsx | 4 +- packages/react/src/hooks/use-scope.ts | 8 ++- packages/react/src/pages/connections.tsx | 4 +- packages/react/src/pages/policies.tsx | 4 +- packages/react/src/pages/secrets.tsx | 6 +- packages/react/src/pages/source-detail.tsx | 4 +- packages/react/src/pages/sources.tsx | 6 +- packages/react/src/pages/tools.tsx | 4 +- 13 files changed, 210 insertions(+), 34 deletions(-) create mode 100644 apps/cloud/src/services/scope-info.node.test.ts diff --git a/apps/cloud/src/services/scope-info.node.test.ts b/apps/cloud/src/services/scope-info.node.test.ts new file mode 100644 index 000000000..fae6ed00f --- /dev/null +++ b/apps/cloud/src/services/scope-info.node.test.ts @@ -0,0 +1,61 @@ +// Scope info handler — verifies that `/scope` returns the active write scope +// id alongside the full scope stack for both global (`/api/:org`) and +// workspace (`/api/:org/:workspace`) URL contexts. +// +// The plan in `notes/cloud-workspaces-and-global-sources-plan.md` ("Executor +// Construction") calls for the API to expose: +// +// - `id` — active display scope (org in global, workspace in workspace). +// - `activeWriteScopeId` — explicit default source-definition write target. +// - `stack` — the full innermost-first stack so the UI can render storage- +// target selectors (user-workspace, workspace, user-org, org). +// +// The handler computes `activeWriteScopeId` by skipping personal scopes +// (`user_*`); the first non-personal scope from the inner end is the active +// target. These tests pin that rule by asserting concrete ids built with the +// same helpers the executor factory uses. + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + asOrg, + asWorkspace, + orgScopeId, + testWorkspaceScopeId, +} from "./__test-harness__/api-harness"; + +describe("scope.info", () => { + it.effect("global context returns activeWriteScope = org_", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + + const info = yield* asOrg(org, (client) => client.scope.info()); + + expect(info.activeWriteScopeId).toBe(orgScopeId(org)); + expect(info.id).toBe(orgScopeId(org)); + // Stack is innermost-first: [user_org, org]. + expect(info.stack).toHaveLength(2); + expect(info.stack[0]!.id).toMatch(/^user_org_/); + expect(info.stack[1]!.id).toBe(orgScopeId(org)); + }), + ); + + it.effect("workspace context returns activeWriteScope = workspace_", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const slug = `ws_${crypto.randomUUID().slice(0, 8)}`; + + const info = yield* asWorkspace(org, slug, (client) => client.scope.info()); + + expect(info.activeWriteScopeId).toBe(testWorkspaceScopeId(org, slug)); + expect(info.id).toBe(testWorkspaceScopeId(org, slug)); + // Stack is innermost-first: [user_workspace, workspace, user_org, org]. + expect(info.stack).toHaveLength(4); + expect(info.stack[0]!.id).toMatch(/^user_workspace_/); + expect(info.stack[1]!.id).toBe(testWorkspaceScopeId(org, slug)); + expect(info.stack[2]!.id).toMatch(/^user_org_/); + expect(info.stack[3]!.id).toBe(orgScopeId(org)); + }), + ); +}); diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 5cb7b8075..a8a36cbe8 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -2,7 +2,7 @@ import { Link, Outlet, useLocation, useNavigate } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { useSourcesWithPending } from "@executor-js/react/api/optimistic"; -import { useScope } from "@executor-js/react/api/scope-context"; +import { useActiveWriteScopeId } from "@executor-js/react/api/scope-context"; import { Button } from "@executor-js/react/components/button"; import { Skeleton } from "@executor-js/react/components/skeleton"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; @@ -140,7 +140,7 @@ function NavItem(props: { function SourceList(props: { pathname: string; onNavigate?: () => void }) { const { orgHandle } = useOrgRoute(); const workspace = useOptionalWorkspaceRoute(); - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const sources = useSourcesWithPending(scopeId); return AsyncResult.match(sources, { diff --git a/packages/core/api/src/handlers/scope.ts b/packages/core/api/src/handlers/scope.ts index d05150a26..c3fd28017 100644 --- a/packages/core/api/src/handlers/scope.ts +++ b/packages/core/api/src/handlers/scope.ts @@ -4,21 +4,59 @@ import { Effect } from "effect"; import { ExecutorApi } from "../api"; import { ExecutorService } from "../services"; import { capture } from "@executor-js/api"; +import { ScopeId } from "@executor-js/sdk"; + +// Compute the active source-definition write scope from the executor's +// innermost-first scope stack. +// +// global stack: [user_org__, org_] -> org_ +// workspace stack: [user_workspace__, workspace_, +// user_org__, org_] -> workspace_ +// +// The rule: skip personal scopes (those whose id starts with `user_`); the +// first non-personal scope from the inner end is the active write target. +// `cloud-workspaces-08` introduced these prefixes via `apps/cloud/src/services/ids.ts`, +// so any deployment running this code already produces them. Local dev with a +// pre-prefix `org_` is unaffected — that id has no `user_` prefix and gets +// picked first either way. +const isUserScope = (id: string): boolean => + id.startsWith("user_org_") || id.startsWith("user_workspace_"); + +const computeActiveWriteScopeId = ( + scopes: ReadonlyArray<{ readonly id: ScopeId }>, +): ScopeId => { + for (const scope of scopes) { + if (!isUserScope(scope.id)) { + return scope.id; + } + } + // Stack is all-personal — fall back to the innermost. Should not happen in + // production (cloud always seeds an org scope), but the type system lets + // callers configure any stack and we don't want a partial response. + const fallback = scopes[0]; + if (!fallback) { + throw new Error("scope.info called with empty executor scope stack"); + } + return fallback.id; +}; export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) => handlers.handle("info", () => capture(Effect.gen(function* () { const executor = yield* ExecutorService; - // `id` / `name` / `dir` continue to point at the outermost scope so - // existing clients keep their source writes org/workspace-scoped. - // `stack` exposes the full innermost-first scope stack so the UI can - // deliberately target per-user secret writes when binding credentials. - const scope = executor.scopes.at(-1)!; + const stack = executor.scopes; + // Active scope drives the UI's default display + source-definition + // writes; stack drives storage-target selectors. See the schema in + // `../scope/api.ts` for the full contract. + const activeWriteScopeId = computeActiveWriteScopeId(stack); + const active = + stack.find((s) => s.id === activeWriteScopeId) ?? stack.at(-1)!; return { - id: scope.id, - name: scope.name, - dir: scope.name, - stack: executor.scopes.map((entry) => ({ + id: active.id, + name: active.name, + dir: active.name, + activeWriteScopeId, + stack: stack.map((entry) => ({ id: entry.id, name: entry.name, dir: entry.name, diff --git a/packages/core/api/src/scope/api.ts b/packages/core/api/src/scope/api.ts index 817b8dda7..3b42997b1 100644 --- a/packages/core/api/src/scope/api.ts +++ b/packages/core/api/src/scope/api.ts @@ -8,10 +8,25 @@ import { InternalError } from "../observability"; // Response schemas // --------------------------------------------------------------------------- +// `id` / `name` / `dir` track the active display/write scope for the current +// URL context — `org_` in global, `workspace_` in +// workspace contexts. Source-definition writes default to this scope; secret +// / connection / policy writes can target any entry in `stack` and the +// caller picks via `activeWriteScopeId` or another scope from the stack. +// +// `stack` is the full executor scope stack, innermost first. UIs use it to +// render storage-target selectors ("Only me in this workspace" → user- +// workspace, "Everyone in this workspace" → workspace, etc.) and to label +// inherited resources by scope. +// +// `activeWriteScopeId` is the default write target — `org` in global, `workspace` +// in workspace contexts. Pre-computed by the server so the UI doesn't have to +// re-derive the "skip user-prefixed scopes" rule. const ScopeInfoResponse = Schema.Struct({ id: ScopeId, name: Schema.String, dir: Schema.String, + activeWriteScopeId: ScopeId, stack: Schema.Array( Schema.Struct({ id: ScopeId, diff --git a/packages/react/src/api/scope-context.tsx b/packages/react/src/api/scope-context.tsx index b44681287..cdb533a46 100644 --- a/packages/react/src/api/scope-context.tsx +++ b/packages/react/src/api/scope-context.tsx @@ -5,6 +5,37 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import type { ScopeId } from "@executor-js/sdk"; import { scopeAtom } from "./atoms"; +// --------------------------------------------------------------------------- +// Scope context — bridges the server's `/scope/info` payload into React. +// +// The server returns three things: +// +// - `id` / `name` / `dir` for the active display/write scope +// (`org_` global, `workspace_` workspace). +// - `activeWriteScopeId` — explicit field for the default source-definition +// write target. Same value as `id` today, but kept distinct so callers can +// opt into "give me the default write target" without depending on the +// display-vs-write distinction blurring later. +// - `stack` — the full innermost-first scope stack. Drives storage-target +// selectors ("Only me in this workspace" → user-workspace, +// "Everyone in this workspace" → workspace, etc.) and inherited resource +// labelling. +// +// Which hook to use: +// +// - `useActiveWriteScopeId()` — default source-definition writes. Use this +// for source list reads (the executor walks the stack on read), source +// refresh/remove operations, and the default selection in add-source UIs. +// - `useUserScope()` — personal-only resources. The innermost scope in the +// stack (user-workspace in workspace context, user-org in global). +// - `useScopeStack()` — for storage-target selectors that need the full +// stack of choices. +// - `useScopeInfo()` — the raw server payload, when a component needs more +// than one piece (display name + active id + stack). +// - `useScope()` — DEPRECATED alias for `useActiveWriteScopeId()`. Existing +// callers continue to work; new code should pick the more specific hook. +// --------------------------------------------------------------------------- + export interface ScopeStackEntry { readonly id: ScopeId; readonly name: string; @@ -15,6 +46,7 @@ export interface ScopeInfo { readonly id: ScopeId; readonly name: string; readonly dir: string; + readonly activeWriteScopeId: ScopeId; readonly stack: readonly ScopeStackEntry[]; } @@ -35,20 +67,33 @@ export function ScopeProvider(props: React.PropsWithChildren<{ fallback?: React. } /** - * Returns the current scope ID. - * Must be used inside a ScopeProvider (which gates rendering until scope is loaded). + * Returns the active display/write scope id. Prefer `useActiveWriteScopeId()` + * for new code — this hook is kept as an alias so existing callers don't + * churn. The two return the same value today. */ export function useScope(): ScopeId { + return useActiveWriteScopeId(); +} + +/** + * Returns the active source-definition write target id. `org_` in global + * context, `workspace_` in workspace context. Reads via this scope walk + * the executor's full stack server-side, so list endpoints called with this + * id include inherited resources from outer scopes. + * + * Must be used inside a ScopeProvider. + */ +export function useActiveWriteScopeId(): ScopeId { const scope = React.useContext(ScopeContext); if (scope === null) { - throw new Error("useScope must be used inside a ScopeProvider"); + throw new Error("useActiveWriteScopeId must be used inside a ScopeProvider"); } - return scope.id; + return scope.activeWriteScopeId; } /** - * Returns the full scope info (id + display name). - * Must be used inside a ScopeProvider. + * Returns the full scope info (id + display name + stack + active write + * target). Must be used inside a ScopeProvider. */ export function useScopeInfo(): ScopeInfo { const scope = React.useContext(ScopeContext); @@ -58,10 +103,21 @@ export function useScopeInfo(): ScopeInfo { return scope; } +/** + * Returns the full innermost-first scope stack. Use this for storage-target + * selectors that need to expose every legal write target ("Only me here", + * "Everyone here", "Only me org-wide", "Everyone org-wide"). + */ export function useScopeStack(): readonly ScopeStackEntry[] { return useScopeInfo().stack; } +/** + * Returns the innermost (most personal) scope id — `user_workspace__` + * in workspace context, `user_org__` in global. Use this for resources + * that are always personal-only (e.g. some OAuth tokens, per-user + * preferences). + */ export function useUserScope(): ScopeId { const stack = useScopeStack(); const innermost = stack[0]; diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx index 4790c803a..849d9ac58 100644 --- a/packages/react/src/components/command-palette.tsx +++ b/packages/react/src/components/command-palette.tsx @@ -5,7 +5,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { PlusIcon } from "lucide-react"; import { SourceFavicon } from "./source-favicon"; import { sourcesAtom } from "../api/atoms"; -import { useScope } from "../hooks/use-scope"; +import { useActiveWriteScopeId } from "../hooks/use-scope"; import { useSourcePlugins } from "@executor-js/sdk/client"; import { CommandDialog, @@ -31,7 +31,7 @@ export function CommandPalette() { const sourcePlugins = useSourcePlugins(); const [open, setOpen] = useState(false); const navigate = useNavigate(); - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const sourcesResult = useAtomValue(sourcesAtom(scopeId)); // Toggle with ⌘K / Ctrl+K diff --git a/packages/react/src/hooks/use-scope.ts b/packages/react/src/hooks/use-scope.ts index 510a846e5..f1bb4f799 100644 --- a/packages/react/src/hooks/use-scope.ts +++ b/packages/react/src/hooks/use-scope.ts @@ -1 +1,7 @@ -export { useScope, useScopeInfo, useScopeStack, useUserScope } from "../api/scope-context"; +export { + useScope, + useActiveWriteScopeId, + useScopeInfo, + useScopeStack, + useUserScope, +} from "../api/scope-context"; diff --git a/packages/react/src/pages/connections.tsx b/packages/react/src/pages/connections.tsx index 9d565f93b..2b105cac5 100644 --- a/packages/react/src/pages/connections.tsx +++ b/packages/react/src/pages/connections.tsx @@ -6,7 +6,7 @@ import { toast } from "sonner"; import { removeConnection } from "../api/atoms"; import { useConnectionsWithPendingRemovals, usePendingConnectionRemovals } from "../api/optimistic"; import { connectionWriteKeys } from "../api/reactivity-keys"; -import { useScope, useScopeStack } from "../hooks/use-scope"; +import { useActiveWriteScopeId, useScopeStack } from "../hooks/use-scope"; import { Badge } from "../components/badge"; import { Button } from "../components/button"; import { @@ -116,7 +116,7 @@ function ConnectionRow(props: { // --------------------------------------------------------------------------- export function ConnectionsPage() { - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const scopeStack = useScopeStack(); const connections = useConnectionsWithPendingRemovals(scopeId); const { beginRemove } = usePendingConnectionRemovals(); diff --git a/packages/react/src/pages/policies.tsx b/packages/react/src/pages/policies.tsx index ca7bfe694..ade532161 100644 --- a/packages/react/src/pages/policies.tsx +++ b/packages/react/src/pages/policies.tsx @@ -12,7 +12,7 @@ import { updatePolicyOptimistic, } from "../api/atoms"; import { policyWriteKeys } from "../api/reactivity-keys"; -import { useScope } from "../hooks/use-scope"; +import { useActiveWriteScopeId } from "../hooks/use-scope"; import { badgeVariants } from "../components/badge"; import { cn } from "../lib/utils"; import { @@ -250,7 +250,7 @@ function PolicyRow(props: { // --------------------------------------------------------------------------- export function PoliciesPage() { - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const policies = useAtomValue(policiesOptimisticAtom(scopeId)); const doCreate = useAtomSet(createPolicyOptimistic(scopeId), { mode: "promise", diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index be9adb754..bda382c98 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -5,7 +5,7 @@ import { secretsAtom, setSecret, removeSecret } from "../api/atoms"; import { secretWriteKeys } from "../api/reactivity-keys"; import { useSecretProviderPlugins } from "@executor-js/sdk/client"; import { SecretId } from "@executor-js/sdk"; -import { useScope } from "../hooks/use-scope"; +import { useActiveWriteScopeId } from "../hooks/use-scope"; import { Dialog, DialogContent, @@ -72,7 +72,7 @@ function AddSecretDialog(props: { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const doSet = useAtomSet(setSecret, { mode: "promise" }); const reset = () => { @@ -289,7 +289,7 @@ export function SecretsPage(props: { "Store a credential or API key. Values are kept in your system keychain when available, with a local encrypted file fallback."; const secretProviderPlugins = useSecretProviderPlugins(); const [addOpen, setAddOpen] = useState(false); - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const secrets = useAtomValue(secretsAtom(scopeId)); const doRemove = useAtomSet(removeSecret, { mode: "promise" }); diff --git a/packages/react/src/pages/source-detail.tsx b/packages/react/src/pages/source-detail.tsx index 8ab2d5b9b..6908aa011 100644 --- a/packages/react/src/pages/source-detail.tsx +++ b/packages/react/src/pages/source-detail.tsx @@ -15,7 +15,7 @@ import { sourceWriteKeys } from "../api/reactivity-keys"; import { ToolTree } from "../components/tool-tree"; import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; import type { ToolSummary } from "../components/tool-tree"; -import { useScope } from "../hooks/use-scope"; +import { useActiveWriteScopeId } from "../hooks/use-scope"; import { usePolicyActions } from "../hooks/use-policy-actions"; import { useSourcePlugins } from "@executor-js/sdk/client"; import { Button } from "../components/button"; @@ -25,7 +25,7 @@ import { Skeleton } from "../components/skeleton"; export function SourceDetailPage(props: { namespace: string }) { const { namespace } = props; const sourcePlugins = useSourcePlugins(); - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const source = useAtomValue(sourceAtom(namespace, scopeId)); const tools = useAtomValue(sourceToolsAtom(namespace, scopeId)); const policies = useAtomValue(policiesOptimisticAtom(scopeId)); diff --git a/packages/react/src/pages/sources.tsx b/packages/react/src/pages/sources.tsx index 336fd99b5..59cccb4ea 100644 --- a/packages/react/src/pages/sources.tsx +++ b/packages/react/src/pages/sources.tsx @@ -11,7 +11,7 @@ import { } from "@executor-js/sdk/client"; import { detectSource } from "../api/atoms"; import { useSourcesWithPending } from "../api/optimistic"; -import { useScope } from "../hooks/use-scope"; +import { useActiveWriteScopeId } from "../hooks/use-scope"; import { McpInstallCard } from "../components/mcp-install-card"; import { Button } from "../components/button"; import { Badge } from "../components/badge"; @@ -59,7 +59,7 @@ const bestDetection = ( // --------------------------------------------------------------------------- export function SourcesPage() { - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const sources = useSourcesWithPending(scopeId); const [connectOpen, setConnectOpen] = useState(false); @@ -137,7 +137,7 @@ const looksLikeUrl = (raw: string): boolean => { function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { const sourcePlugins = useSourcePlugins(); - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const doDetect = useAtomSet(detectSource, { mode: "promise" }); const navigate = useNavigate(); diff --git a/packages/react/src/pages/tools.tsx b/packages/react/src/pages/tools.tsx index 2b2baa930..6ece963d2 100644 --- a/packages/react/src/pages/tools.tsx +++ b/packages/react/src/pages/tools.tsx @@ -5,7 +5,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { effectivePolicyFromSorted } from "@executor-js/sdk"; import { policiesOptimisticAtom, toolsAtom } from "../api/atoms"; -import { useScope } from "../hooks/use-scope"; +import { useActiveWriteScopeId } from "../hooks/use-scope"; import { usePolicyActions } from "../hooks/use-policy-actions"; import { ToolTree, type ToolSummary } from "../components/tool-tree"; import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; @@ -13,7 +13,7 @@ import { Button } from "../components/button"; import { Skeleton } from "../components/skeleton"; export function ToolsPage() { - const scopeId = useScope(); + const scopeId = useActiveWriteScopeId(); const tools = useAtomValue(toolsAtom(scopeId)); const policies = useAtomValue(policiesOptimisticAtom(scopeId)); const policyActions = usePolicyActions(scopeId);