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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/cloud/src/services/scope-info.node.test.ts
Original file line number Diff line number Diff line change
@@ -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_<id>", () =>
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_<id>", () =>
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));
}),
);
});
4 changes: 2 additions & 2 deletions apps/cloud/src/web/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, {
Expand Down
56 changes: 47 additions & 9 deletions packages/core/api/src/handlers/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<u>_<o>, org_<o>] -> org_<o>
// workspace stack: [user_workspace_<u>_<w>, workspace_<w>,
// user_org_<u>_<o>, org_<o>] -> workspace_<w>
//
// 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_<id>` 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,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/api/src/scope/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<orgId>` in global, `workspace_<workspaceId>` 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,
Expand Down
68 changes: 62 additions & 6 deletions packages/react/src/api/scope-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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_<id>` global, `workspace_<id>` 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;
Expand All @@ -15,6 +46,7 @@ export interface ScopeInfo {
readonly id: ScopeId;
readonly name: string;
readonly dir: string;
readonly activeWriteScopeId: ScopeId;
readonly stack: readonly ScopeStackEntry[];
}

Expand All @@ -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_<id>` in global
* context, `workspace_<id>` 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);
Expand All @@ -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_<u>_<w>`
* in workspace context, `user_org_<u>_<o>` 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];
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/components/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/hooks/use-scope.ts
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
export { useScope, useScopeInfo, useScopeStack, useUserScope } from "../api/scope-context";
export {
useScope,
useActiveWriteScopeId,
useScopeInfo,
useScopeStack,
useUserScope,
} from "../api/scope-context";
4 changes: 2 additions & 2 deletions packages/react/src/pages/connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/pages/policies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/react/src/pages/secrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -72,7 +72,7 @@ function AddSecretDialog(props: {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

const scopeId = useScope();
const scopeId = useActiveWriteScopeId();
const doSet = useAtomSet(setSecret, { mode: "promise" });

const reset = () => {
Expand Down Expand Up @@ -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" });

Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/pages/source-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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));
Expand Down
Loading
Loading