Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/playground-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/cli": patch
"@cloudhal/storage": patch
---

The CLI ships a freshly built Studio: 0.3.0 shipped one built before Studio's playground mode, so a playground opened at the sign-in page. On a playground, Studio's site sidebar also leaves out Analytics, Activity, Approvals, Agents and Schedules, and a `403 playground` answer reads as a feature that is off (no retries, no error states). `FetcherBlobStore` with `keys` never fetches another key: a static-assets binding with SPA fallback answers 200 for missing files.
3 changes: 2 additions & 1 deletion apps/studio/src/components/usePendingApprovals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ const pendingQuery = (siteId: string) => ({
* (the site shell, so only while the site surface is open). A server without approvals answers
* 404 once and is not polled again.
*/
export function usePendingApprovals(siteId: string) {
export function usePendingApprovals(siteId: string, enabled = true) {
return useQuery({
...pendingQuery(siteId),
enabled,
refetchInterval: (query) => (isNotEnabled(query.state.error) ? false : APPROVALS_POLL_MS),
});
}
Expand Down
5 changes: 3 additions & 2 deletions apps/studio/src/components/usePlayground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export function isPlaygroundBlockedPath(pathname: string): boolean {
return /^\/(agents|team|account)(\/|$)/.test(pathname);
}

/** Whole minutes until `expiresAt`, never below zero. */
/** Whole minutes until `expiresAt`: 0 once it has passed, at least 1 before. */
export function minutesLeft(expiresAt: string, now: number): number {
return Math.max(0, Math.ceil((Date.parse(expiresAt) - now) / 60_000));
const ms = Date.parse(expiresAt) - now;
return ms <= 0 ? 0 : Math.max(1, Math.floor(ms / 60_000));
}
4 changes: 3 additions & 1 deletion apps/studio/src/lib/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export function query(params: Record<string, string | number | undefined | null>

/** True when the server answered 404 for a feature it does not run (audit log, agent keys). */
export function isNotEnabled(error: unknown): boolean {
return error instanceof ApiRequestError && error.status === 404;
if (!(error instanceof ApiRequestError)) return false;
// RFC-0036 §5: a playground refuses what it leaves out with 403 `playground`; read it as off.
return error.status === 404 || (error.status === 403 && error.body.error === "playground");
}

/** The API error code (`body.error`) of a failed request, e.g. `rank` or `invite_gone`. */
Expand Down
194 changes: 116 additions & 78 deletions apps/studio/src/routes/SiteShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useTaxonomyData } from "../components/Taxonomy.tsx";
import { useCan } from "../components/useMe.ts";
import { useNotifications } from "../components/useNotifications.ts";
import { usePendingApprovals } from "../components/usePendingApprovals.ts";
import { usePlayground } from "../components/usePlayground.ts";
import { useSitePalette } from "../components/useSitePalette.ts";
import { useT } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
Expand All @@ -49,8 +50,10 @@ export function SiteShell() {
queryKey: ["snapshots", siteId],
queryFn: () => api.snapshots.list(siteId),
});
// RFC-0036 §5: a playground has no analytics, activity log, approvals, agents or schedules.
const full = !usePlayground();
// Polls while the site surface is open; drives the sidebar badge (and the overview banner).
const approvals = usePendingApprovals(siteId);
const approvals = usePendingApprovals(siteId, full);
const pendingApprovals = approvals.data?.length ?? 0;
// Proposed terms wait for a person (RFC-0002 §8): their count rides on the sidebar item.
const taxonomy = useTaxonomyData(siteId);
Expand Down Expand Up @@ -121,14 +124,16 @@ export function SiteShell() {
>
{t("nav.home")}
</Sidebar.MenuButton>
<Sidebar.MenuButton
icon={ChartLine}
href={`${base}/analytics`}
active={is(`${base}/analytics`)}
tooltip={t("nav.analytics")}
>
{t("nav.analytics")}
</Sidebar.MenuButton>
{full && (
<Sidebar.MenuButton
icon={ChartLine}
href={`${base}/analytics`}
active={is(`${base}/analytics`)}
tooltip={t("nav.analytics")}
>
{t("nav.analytics")}
</Sidebar.MenuButton>
)}
</Sidebar.Menu>
</Sidebar.Group>
<Sidebar.Group>
Expand Down Expand Up @@ -190,75 +195,14 @@ export function SiteShell() {
)}
</Sidebar.Menu>
</Sidebar.Group>
<Sidebar.Group>
<Sidebar.GroupLabel>{t("nav.history")}</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuButton
icon={Camera}
href={`${base}/snapshots`}
active={is(`${base}/snapshots`)}
tooltip={t("nav.snapshots")}
>
{t("nav.snapshots")}
<Sidebar.MenuBadge>{snapshots.data?.length ?? 0}</Sidebar.MenuBadge>
</Sidebar.MenuButton>
<Sidebar.MenuButton
icon={ClockCounterClockwise}
href={`${base}/activity`}
active={is(`${base}/activity`)}
tooltip={t("nav.activity")}
>
{t("nav.activity")}
</Sidebar.MenuButton>
<Sidebar.MenuButton
icon={HandPalm}
href={`${base}/approvals`}
active={is(`${base}/approvals`)}
tooltip={t("nav.approvals")}
>
{t("nav.approvals")}
{pendingApprovals > 0 && <Sidebar.MenuBadge>{pendingApprovals}</Sidebar.MenuBadge>}
</Sidebar.MenuButton>
</Sidebar.Menu>
</Sidebar.Group>
<Sidebar.Group>
<Sidebar.GroupLabel>{t("nav.automation")}</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.Collapsible defaultOpen>
<Sidebar.CollapsibleTrigger
render={
<Sidebar.MenuButton icon={Robot} tooltip={t("nav.agents")}>
{t("nav.agents")}
<Sidebar.MenuChevron />
</Sidebar.MenuButton>
}
/>
<Sidebar.CollapsibleContent>
<Sidebar.MenuSub>
<Sidebar.MenuSubButton href="/agents">
{t("nav.agentKeys")}
</Sidebar.MenuSubButton>
<Sidebar.MenuSubButton href="/agents/skills">
{t("nav.agentSkills")}
</Sidebar.MenuSubButton>
</Sidebar.MenuSub>
</Sidebar.CollapsibleContent>
</Sidebar.Collapsible>
</Sidebar.MenuItem>
<Sidebar.MenuButton
icon={CalendarDots}
href={`${base}/schedules`}
active={is(`${base}/schedules`)}
tooltip={t("nav.schedules")}
>
{t("nav.schedules")}
{scheduleAttention > 0 && (
<Sidebar.MenuBadge>{scheduleAttention}</Sidebar.MenuBadge>
)}
</Sidebar.MenuButton>
</Sidebar.Menu>
</Sidebar.Group>
<HistoryGroup
base={base}
is={is}
full={full}
snapshots={snapshots.data?.length ?? 0}
pendingApprovals={pendingApprovals}
/>
{full && <AutomationGroup base={base} is={is} attention={scheduleAttention} />}
<SettingsGroup base={base} is={is} />
</>
}
Expand Down Expand Up @@ -304,3 +248,97 @@ function SettingsGroup({ base, is }: { base: string; is: (path: string) => boole
</Sidebar.Group>
);
}

interface GroupProps {
base: string;
is: (path: string) => boolean;
}

/** The sidebar's History group: snapshots, and (not on a playground) activity and approvals. */
function HistoryGroup({
base,
is,
full,
snapshots,
pendingApprovals,
}: GroupProps & { full: boolean; snapshots: number; pendingApprovals: number }) {
const t = useT();
return (
<Sidebar.Group>
<Sidebar.GroupLabel>{t("nav.history")}</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuButton
icon={Camera}
href={`${base}/snapshots`}
active={is(`${base}/snapshots`)}
tooltip={t("nav.snapshots")}
>
{t("nav.snapshots")}
<Sidebar.MenuBadge>{snapshots}</Sidebar.MenuBadge>
</Sidebar.MenuButton>
{full && (
<>
<Sidebar.MenuButton
icon={ClockCounterClockwise}
href={`${base}/activity`}
active={is(`${base}/activity`)}
tooltip={t("nav.activity")}
>
{t("nav.activity")}
</Sidebar.MenuButton>
<Sidebar.MenuButton
icon={HandPalm}
href={`${base}/approvals`}
active={is(`${base}/approvals`)}
tooltip={t("nav.approvals")}
>
{t("nav.approvals")}
{pendingApprovals > 0 && <Sidebar.MenuBadge>{pendingApprovals}</Sidebar.MenuBadge>}
</Sidebar.MenuButton>
</>
)}
</Sidebar.Menu>
</Sidebar.Group>
);
}

/** The sidebar's Automation group: agents and schedules. */
function AutomationGroup({ base, is, attention }: GroupProps & { attention: number }) {
const t = useT();
return (
<Sidebar.Group>
<Sidebar.GroupLabel>{t("nav.automation")}</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.Collapsible defaultOpen>
<Sidebar.CollapsibleTrigger
render={
<Sidebar.MenuButton icon={Robot} tooltip={t("nav.agents")}>
{t("nav.agents")}
<Sidebar.MenuChevron />
</Sidebar.MenuButton>
}
/>
<Sidebar.CollapsibleContent>
<Sidebar.MenuSub>
<Sidebar.MenuSubButton href="/agents">{t("nav.agentKeys")}</Sidebar.MenuSubButton>
<Sidebar.MenuSubButton href="/agents/skills">
{t("nav.agentSkills")}
</Sidebar.MenuSubButton>
</Sidebar.MenuSub>
</Sidebar.CollapsibleContent>
</Sidebar.Collapsible>
</Sidebar.MenuItem>
<Sidebar.MenuButton
icon={CalendarDots}
href={`${base}/schedules`}
active={is(`${base}/schedules`)}
tooltip={t("nav.schedules")}
>
{t("nav.schedules")}
{attention > 0 && <Sidebar.MenuBadge>{attention}</Sidebar.MenuBadge>}
</Sidebar.MenuButton>
</Sidebar.Menu>
</Sidebar.Group>
);
}
11 changes: 10 additions & 1 deletion apps/studio/test/playground.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { PlaygroundSessionBar } from "../src/components/PlaygroundSessionBar.tsx";
import { PlaygroundUnavailable } from "../src/components/PlaygroundUnavailable.tsx";
import { isPlaygroundBlockedPath, minutesLeft } from "../src/components/usePlayground.ts";
import { ApiRequestError, isNotEnabled } from "../src/lib/api/http.ts";
import { INSTANCE, on, serve, wrap } from "./roles-fixtures.tsx";

afterEach(() => {
Expand Down Expand Up @@ -32,6 +33,14 @@ describe("playground helpers", () => {
expect(minutesLeft("2026-10-20T17:59:00Z", now)).toBe(0);
});

it("reads a playground refusal as a feature that is off", () => {
const refusal = (status: number, error: string) =>
new ApiRequestError(status, { error, message: "x" } as never);
expect(isNotEnabled(refusal(403, "playground"))).toBe(true);
expect(isNotEnabled(refusal(404, "not_found"))).toBe(true);
expect(isNotEnabled(refusal(403, "forbidden"))).toBe(false);
});

it("knows the account screens a playground leaves out", () => {
for (const p of ["/team", "/agents", "/agents/skills", "/account"])
expect(isPlaygroundBlockedPath(p), p).toBe(true);
Expand All @@ -49,7 +58,7 @@ describe("PlaygroundSessionBar", () => {
});

it("shows the time left and Deploy, and Reset deletes the session", async () => {
const expires = new Date(Date.now() + 41 * 60_000 - 1000).toISOString();
const expires = new Date(Date.now() + 41 * 60_000 + 30_000).toISOString();
const fetch = serve(
on("GET", "/api/instance", { status: 200, body: playground(expires) }),
on("POST", "/_playground/reset", { status: 204 }),
Expand Down
2 changes: 1 addition & 1 deletion docs/design/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ Site › Settings gets an **Appearance** section after Domains (`components/Appe
### 2af. Playground session (RFC-0036 §5, ADR-0038)
- Only when `/api/instance` reports `features.playground` (try.cloudhal.com). Studio has no sign-in there: `requireSession` accepts a 403 from `/api/auth` when the instance is a playground.
- **Session bar**: `PlaygroundBar` under the top bar on every screen — a compact secondary `Banner` (`Timer`), title "Playground", the minutes left as an ICU plural with the privacy note, a ghost "Reset" (`POST resetUrl`, then the landing page) and a primary "Deploy to Cloudflare" `LinkButton` (external). At zero minutes the browser returns to the landing page.
- **Left out**: the account surface's Agents group and Team/Account items (and their palette commands), the user menu's Account, Team and Sign out. A direct visit to `/agents`, `/team` or `/account` shows `PlaygroundUnavailable`: a bare `Empty` (`LockSimple`) "Available in your own install" with the Deploy button. Site screens are gated as everywhere else, by `features.*` and the principal's abilities (no uploads, analytics, comments, schedules, AI, preview links).
- **Left out**: the account surface's Agents group and Team/Account items (and their palette commands), the user menu's Account, Team and Sign out. A direct visit to `/agents`, `/team` or `/account` shows `PlaygroundUnavailable`: a bare `Empty` (`LockSimple`) "Available in your own install" with the Deploy button. The site sidebar leaves out Analytics, Activity, Approvals and the Automation group (Agents, Schedules). Elsewhere site screens are gated as everywhere else, by `features.*` and the principal's abilities; a `403 playground` answer counts as a feature that is off (`isNotEnabled`).

## 3. Sidebar specification
```tsx
Expand Down
10 changes: 9 additions & 1 deletion packages/storage/src/blob/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export interface BlobFetcher {
}

export interface FetcherBlobStoreOptions {
/** Keys `list` reports (static assets cannot be listed). Default: none. */
/**
* The keys this store holds. With it, `get`, `head` and `list` know only these keys and never
* fetch another: a static-assets binding with SPA fallback answers 200 with `index.html` for any
* missing file, which would otherwise read as a blob. Without it, every key is fetched and
* `list` reports none (static assets cannot be listed).
*/
readonly keys?: readonly string[];
}

Expand All @@ -26,15 +31,18 @@ export class FetcherBlobStore implements BlobStore {
readonly #fetcher: BlobFetcher;
readonly #prefix: string;
readonly #keys: readonly string[];
readonly #known: ReadonlySet<string> | undefined;

constructor(fetcher: BlobFetcher, prefix: string, options: FetcherBlobStoreOptions = {}) {
this.#fetcher = fetcher;
this.#prefix = prefix.replace(/^\/+/, "");
this.#keys = [...(options.keys ?? [])].sort();
this.#known = options.keys ? new Set(options.keys) : undefined;
}

async #fetch(key: string): Promise<Response | null> {
assertBlobKey(key);
if (this.#known && !this.#known.has(key)) return null;
const res = await this.#fetcher.fetch(`https://assets.local/${this.#prefix}${key}`);
if (res.status === 404) return null;
if (!res.ok) throw new StorageError(`fetching ${key} failed with ${res.status}`);
Expand Down
8 changes: 8 additions & 0 deletions packages/storage/test/blob-layers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ describe("FetcherBlobStore", () => {
expect(fetcher.calls).toEqual([]);
});

it("never fetches a key outside its list (SPA fallback answers 200 for anything)", async () => {
const spa = { fetch: async () => new Response("<!doctype html>studio", { status: 200 }) };
const store = new FetcherBlobStore(spa, "demo/", { keys: ["media/a.png"] });
expect(await store.get("nodes/abc.json")).toBeNull();
expect(await store.head("nodes/abc.json")).toBeNull();
expect(await store.get("media/a.png")).not.toBeNull();
});

it("reports other failures as StorageError", async () => {
const store = new FetcherBlobStore(
{ fetch: async () => new Response("", { status: 500 }) },
Expand Down
11 changes: 3 additions & 8 deletions scripts/build-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,9 @@ async function buildCliAssets(): Promise<void> {
)) as typeof import("../packages/cli/src/starter-files.ts");
const assets = path.join(ROOT, "packages/cli/dist/assets");
const studio = path.join(ROOT, "apps/studio/dist");
try {
await stat(path.join(studio, "index.html"));
} catch {
execFileSync("pnpm", ["--filter", "@cloudhal/studio", "build"], {
stdio: "inherit",
cwd: ROOT,
});
}
// Always a fresh build: an existing dist may predate the sources being released (0.3.0 shipped
// a Studio from before its playground mode this way).
execFileSync("pnpm", ["--filter", "@cloudhal/studio", "build"], { stdio: "inherit", cwd: ROOT });
await cp(studio, path.join(assets, "studio"), {
recursive: true,
filter: (src) => !src.endsWith(".map"),
Expand Down
2 changes: 1 addition & 1 deletion scripts/size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@
"lines": 164
},
"apps/studio/src/routes/SiteShell.tsx::SiteShell": {
"lines": 235
"lines": 178
},
"apps/studio/src/routes/SkillsPage.tsx::SkillsPage": {
"lines": 170
Expand Down
Loading