Skip to content
Closed
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
125 changes: 125 additions & 0 deletions apps/cloud/src/services/sources-shadowing.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Workspace + global source listing — verifies that when a workspace source
// shadows a global source by namespace, both rows show up in
// `sources.list` from workspace context, the inner workspace row has no
// `overriddenBy`, and the outer global row carries the workspace scope id
// in its `overriddenBy` field. The cloud sidebar renders the latter as a
// muted `Overridden` entry; see `apps/cloud/src/web/shell.tsx#SourceList`.

import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import {
asOrg,
asWorkspace,
orgScopeId,
testWorkspaceScopeId,
} from "./__test-harness__/api-harness";

const SHADOW_SPEC = JSON.stringify({
openapi: "3.0.0",
info: { title: "Shadow API", version: "1.0.0" },
paths: {
"/ping": {
get: {
operationId: "ping",
summary: "ping",
responses: { "200": { description: "ok" } },
},
},
},
});

describe("sources.list with workspace + global shadowing", () => {
it.effect(
"returns both rows with scopeId + overriddenBy when workspace shadows a global namespace",
() =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const slug = `ws_${crypto.randomUUID().slice(0, 8)}`;
const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`;
const orgScope = orgScopeId(org);
const wsScope = testWorkspaceScopeId(org, slug);

// Add a global source first.
yield* asOrg(org, (client) =>
client.openapi.addSpec({
params: { scopeId: orgScope },
payload: { spec: SHADOW_SPEC, namespace },
}),
);

// Then add a workspace source under the same namespace, which
// shadows the global one in this workspace.
yield* asWorkspace(org, slug, (client) =>
client.openapi.addSpec({
params: { scopeId: wsScope },
payload: { spec: SHADOW_SPEC, namespace },
}),
);

// Listing from workspace context — both rows should be returned,
// with the outer global row marked `overriddenBy: <workspaceScope>`.
const wsSources = yield* asWorkspace(org, slug, (client) =>
client.sources.list({ params: { scopeId: wsScope } }),
);
const matches = wsSources.filter((s) => s.id === namespace);
expect(matches).toHaveLength(2);

const effective = matches.find((s) => s.overriddenBy === undefined);
const shadowed = matches.find((s) => s.overriddenBy !== undefined);
expect(effective).toBeDefined();
expect(shadowed).toBeDefined();
expect(effective!.scopeId).toBe(wsScope);
expect(shadowed!.scopeId).toBe(orgScope);
expect(shadowed!.overriddenBy).toBe(wsScope);

// Listing from global context — only the global row, no override.
const orgSources = yield* asOrg(org, (client) =>
client.sources.list({ params: { scopeId: orgScope } }),
);
const orgMatches = orgSources.filter((s) => s.id === namespace);
expect(orgMatches).toHaveLength(1);
expect(orgMatches[0]!.scopeId).toBe(orgScope);
expect(orgMatches[0]!.overriddenBy).toBeUndefined();
}),
);

it.effect(
"non-shadowing workspace + global namespaces both appear without override flags",
() =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const slug = `ws_${crypto.randomUUID().slice(0, 8)}`;
const wsNamespace = `ws_only_${crypto.randomUUID().replace(/-/g, "_")}`;
const orgNamespace = `org_only_${crypto.randomUUID().replace(/-/g, "_")}`;
const orgScope = orgScopeId(org);
const wsScope = testWorkspaceScopeId(org, slug);

yield* asOrg(org, (client) =>
client.openapi.addSpec({
params: { scopeId: orgScope },
payload: { spec: SHADOW_SPEC, namespace: orgNamespace },
}),
);
yield* asWorkspace(org, slug, (client) =>
client.openapi.addSpec({
params: { scopeId: wsScope },
payload: { spec: SHADOW_SPEC, namespace: wsNamespace },
}),
);

const wsSources = yield* asWorkspace(org, slug, (client) =>
client.sources.list({ params: { scopeId: wsScope } }),
);

const wsRow = wsSources.find((s) => s.id === wsNamespace);
const orgRow = wsSources.find((s) => s.id === orgNamespace);
expect(wsRow).toBeDefined();
expect(orgRow).toBeDefined();
expect(wsRow!.scopeId).toBe(wsScope);
expect(orgRow!.scopeId).toBe(orgScope);
expect(wsRow!.overriddenBy).toBeUndefined();
expect(orgRow!.overriddenBy).toBeUndefined();
}),
);
});
225 changes: 179 additions & 46 deletions apps/cloud/src/web/shell.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Link, Outlet, useLocation, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useAtomValue } from "@effect/atom-react";
import { useSourcesWithPending } from "@executor-js/react/api/optimistic";
import { useActiveWriteScopeId } from "@executor-js/react/api/scope-context";
import {
useActiveWriteScopeId,
useScopeStack,
} 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 @@ -137,12 +140,100 @@ function NavItem(props: {

// ── SourceList ───────────────────────────────────────────────────────────

// A source in the listing — taken from the API response shape so we can
// reason about scope buckets and override state without a second type
// declaration. Mirrors `Source` from `@executor-js/sdk` minus optimistic
// flags added by `useSourcesWithPending`.
type SidebarSource = {
readonly id: string;
readonly name: string;
readonly kind: string;
readonly url?: string;
readonly scopeId?: string;
readonly overriddenBy?: string;
};

function SourceLink(props: {
source: SidebarSource;
pathname: string;
orgHandle: string;
workspaceSlug: string | null;
onNavigate?: () => void;
overridden?: boolean;
}) {
const { source: s, pathname, orgHandle, workspaceSlug, onNavigate, overridden } = props;
const detailPath = workspaceSlug
? `/${orgHandle}/${workspaceSlug}/sources/${s.id}`
: `/${orgHandle}/sources/${s.id}`;
const active = pathname === detailPath || pathname.startsWith(`${detailPath}/`);
const to = workspaceSlug
? "/$org/$workspace/sources/$namespace"
: "/$org/sources/$namespace";
const params: Record<string, string> = workspaceSlug
? { org: orgHandle, workspace: workspaceSlug, namespace: s.id }
: { org: orgHandle, namespace: s.id };
return (
<Link
to={to as never}
params={params as never}
onClick={onNavigate}
className={[
"group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",
overridden
? "text-muted-foreground opacity-60 hover:opacity-80"
: active
? "bg-sidebar-active text-foreground font-medium"
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
<SourceFavicon url={s.url} />
<span className="flex-1 truncate">{s.name}</span>
{overridden ? (
<span className="rounded bg-muted px-1 py-px text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Overridden
</span>
) : (
<span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground">
{s.kind}
</span>
)}
</Link>
);
}

function SidebarSectionLabel(props: { children: ReactNode }) {
return (
<div className="mt-3 mb-1 px-2.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{props.children}
</div>
);
}

function SourceList(props: { pathname: string; onNavigate?: () => void }) {
const { orgHandle } = useOrgRoute();
const workspace = useOptionalWorkspaceRoute();
const scopeId = useActiveWriteScopeId();
const stack = useScopeStack();
const sources = useSourcesWithPending(scopeId);

// Identify which scopes count as "workspace bucket" vs "global bucket".
// The executor builds the workspace stack as
// `[user_workspace, workspace, user_org, org]`. Sources owned by either
// of the first two scopes are workspace sources; the rest are global
// (including `user-org` overrides, which v1 doesn't write but we won't
// hide if they exist).
const workspaceScopes = new Set<string>();
const globalScopes = new Set<string>();
if (workspace) {
for (const s of stack) {
if (s.id.startsWith("workspace_") || s.id.startsWith("user_workspace_")) {
workspaceScopes.add(s.id);
} else {
globalScopes.add(s.id);
}
}
}

return AsyncResult.match(sources, {
onInitial: () => (
<div className="flex flex-col gap-1 px-2.5 py-1">
Expand All @@ -157,52 +248,94 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) {
onFailure: () => (
<div className="px-2.5 py-2 text-xs text-muted-foreground">No sources yet</div>
),
onSuccess: ({ value }) =>
value.length === 0 ? (
<div className="px-2.5 py-2 text-sm leading-relaxed text-muted-foreground">
No sources yet
</div>
) : (
onSuccess: ({ value }) => {
const all = value as readonly SidebarSource[];
if (all.length === 0) {
return (
<div className="px-2.5 py-2 text-sm leading-relaxed text-muted-foreground">
No sources yet
</div>
);
}

// Global context — flat list, no buckets.
if (!workspace) {
return (
<div className="flex flex-col gap-px">
{all.map((s) => (
<SourceLink
key={`${s.id}-${s.scopeId ?? "static"}`}
source={s}
pathname={props.pathname}
orgHandle={orgHandle}
workspaceSlug={null}
onNavigate={props.onNavigate}
overridden={Boolean(s.overriddenBy)}
/>
))}
</div>
);
}

// Workspace context — split into Workspace + Global buckets, with
// shadowed global sources rendered as `Overridden` (still listed so
// the user can see what's inherited and where the override comes
// from).
const ws: SidebarSource[] = [];
const global: SidebarSource[] = [];
for (const s of all) {
if (s.scopeId && workspaceScopes.has(s.scopeId)) {
ws.push(s);
} else if (s.scopeId && globalScopes.has(s.scopeId)) {
global.push(s);
} else {
// Static sources (no scopeId) and rows from scopes outside this
// request's stack land in the global bucket — they're not owned
// by the workspace.
global.push(s);
}
}

return (
<div className="flex flex-col gap-px">
{value.map((s) => {
const detailPath = workspace
? `/${orgHandle}/${workspace.workspaceSlug}/sources/${s.id}`
: `/${orgHandle}/sources/${s.id}`;
const active =
props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
const to = workspace
? "/$org/$workspace/sources/$namespace"
: "/$org/sources/$namespace";
const params: Record<string, string> = workspace
? {
org: orgHandle,
workspace: workspace.workspaceSlug,
namespace: s.id,
}
: { org: orgHandle, namespace: s.id };
return (
<Link
key={s.id}
to={to as never}
params={params as never}
onClick={props.onNavigate}
className={[
"group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",
active
? "bg-sidebar-active text-foreground font-medium"
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
<SourceFavicon url={s.url} />
<span className="flex-1 truncate">{s.name}</span>
<span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground">
{s.kind}
</span>
</Link>
);
})}
<SidebarSectionLabel>Workspace</SidebarSectionLabel>
{ws.length === 0 ? (
<div className="px-2.5 py-1 text-xs text-muted-foreground">
No workspace sources
</div>
) : (
ws.map((s) => (
<SourceLink
key={`${s.id}-${s.scopeId ?? "static"}`}
source={s}
pathname={props.pathname}
orgHandle={orgHandle}
workspaceSlug={workspace.workspaceSlug}
onNavigate={props.onNavigate}
/>
))
)}
<SidebarSectionLabel>Global</SidebarSectionLabel>
{global.length === 0 ? (
<div className="px-2.5 py-1 text-xs text-muted-foreground">
No global sources
</div>
) : (
global.map((s) => (
<SourceLink
key={`${s.id}-${s.scopeId ?? "static"}`}
source={s}
pathname={props.pathname}
orgHandle={orgHandle}
workspaceSlug={workspace.workspaceSlug}
onNavigate={props.onNavigate}
overridden={Boolean(s.overriddenBy)}
/>
))
)}
</div>
),
);
},
});
}

Expand Down
3 changes: 3 additions & 0 deletions packages/core/api/src/handlers/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han
canRemove: s.canRemove,
canRefresh: s.canRefresh,
canEdit: s.canEdit,
overriddenBy: s.overriddenBy
? ScopeId.make(s.overriddenBy)
: undefined,
}));
})),
)
Expand Down
3 changes: 3 additions & 0 deletions packages/core/api/src/sources/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const SourceResponse = Schema.Struct({
canRemove: Schema.optional(Schema.Boolean),
canRefresh: Schema.optional(Schema.Boolean),
canEdit: Schema.optional(Schema.Boolean),
/** Set when an inner scope has another row with the same id. The UI
* renders this row as muted/disabled with an `Overridden` badge. */
overriddenBy: Schema.optional(ScopeId),
});

const SourceRemoveResponse = Schema.Struct({
Expand Down
Loading
Loading