From b601df47e54ed280b68255965d8796b4d406985a Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 6 Mar 2026 07:30:23 -0500 Subject: [PATCH] Per-request demo mode with UI toggle Replace the server-level `--demo` flag with per-request demo dispatch. The Rust API builds both a demo and real router; incoming requests with the `X-Arc-Demo: 1` header hit the demo router (auth disabled, static data), all others hit the real router with normal auth. The React web app gets a beaker icon toggle in the top nav bar (next to the theme toggle) that sets an `arc-demo` cookie. Loaders read the cookie to decide whether to send the `X-Arc-Demo: 1` header to the API. The `ARC_DEMO=1` env var still works as a default when no cookie is set. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- apps/arc-web/app/api-client.ts | 4 + apps/arc-web/app/layouts/app-shell.tsx | 72 +++++- apps/arc-web/app/lib/demo-mode.server.ts | 15 ++ crates/arc-api/src/demo/mod.rs | 66 +++-- crates/arc-api/src/jwt_auth.rs | 2 +- crates/arc-api/src/serve.rs | 72 +++--- crates/arc-api/src/server.rs | 299 +++++++++++++---------- crates/arc-api/tests/pagination.rs | 5 +- docs/administration/advanced-setup.mdx | 1 - docs/getting-started/quick-start.mdx | 6 +- docs/reference/architecture.mdx | 6 +- docs/reference/cli.mdx | 4 +- entrypoint.ts | 2 +- 14 files changed, 312 insertions(+), 244 deletions(-) create mode 100644 apps/arc-web/app/lib/demo-mode.server.ts diff --git a/CLAUDE.md b/CLAUDE.md index f5af33cb6..d47edcc72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Dev servers -1. `arc serve --demo` — starts the Rust API server in demo mode +1. `arc serve` — starts the Rust API server (demo mode is per-request via `X-Arc-Demo: 1` header) 2. `cd apps/arc-web && bun run dev` — starts the React dev server ## API workflow diff --git a/apps/arc-web/app/api-client.ts b/apps/arc-web/app/api-client.ts index 5afea2bd1..caf2cf5dd 100644 --- a/apps/arc-web/app/api-client.ts +++ b/apps/arc-web/app/api-client.ts @@ -1,5 +1,6 @@ import { importPKCS8, SignJWT } from "jose"; import { getAppConfig } from "./lib/config.server"; +import { isDemoMode } from "./lib/demo-mode.server"; import { getUser } from "./lib/session.server"; const ARC_JWT_PRIVATE_KEY = process.env.ARC_JWT_PRIVATE_KEY; @@ -57,6 +58,9 @@ export async function apiFetch( const token = await signToken(sub); headers.set("Authorization", `Bearer ${token}`); } + if (request && isDemoMode(request)) { + headers.set("X-Arc-Demo", "1"); + } const url = `${base_url}${path}`; try { diff --git a/apps/arc-web/app/layouts/app-shell.tsx b/apps/arc-web/app/layouts/app-shell.tsx index bb943979d..4d14bd68f 100644 --- a/apps/arc-web/app/layouts/app-shell.tsx +++ b/apps/arc-web/app/layouts/app-shell.tsx @@ -9,6 +9,7 @@ import { } from "@headlessui/react"; import { Bars3Icon, + BeakerIcon, ChartBarIcon, CheckBadgeIcon, Cog6ToothIcon, @@ -23,6 +24,7 @@ import { import { Form, Link, Outlet, redirect, useLocation, useMatches } from "react-router"; import { useTheme } from "../lib/theme"; import { getAppConfig } from "../lib/config.server"; +import { isDemoMode, demoCookieHeader } from "../lib/demo-mode.server"; import { isGitHubAppConfigured } from "../lib/github.server"; import { requireUser } from "../lib/session.server"; import type { Route } from "./+types/app-shell"; @@ -36,15 +38,29 @@ const DEMO_USER = { }; export async function loader({ request }: Route.LoaderArgs) { - const { provider } = getAppConfig().web.auth; + const config = getAppConfig(); + const { provider } = config.web.auth; + const demoMode = isDemoMode(request); if (provider === "insecure_disabled") { - return { user: DEMO_USER }; + return { user: DEMO_USER, demoMode, feature_flags: config.feature_flags }; } if (provider === "github" && !isGitHubAppConfigured()) { throw redirect("/setup"); } const user = await requireUser(request); - return { user, provider }; + return { user, provider, demoMode, feature_flags: config.feature_flags }; +} + +export async function action({ request }: Route.ActionArgs) { + const form = await request.formData(); + if (form.get("intent") === "toggle-demo") { + const enabled = !isDemoMode(request); + const referer = request.headers.get("Referer") ?? "/start"; + const url = new URL(referer); + return redirect(url.pathname + url.search, { + headers: { "Set-Cookie": demoCookieHeader(enabled) }, + }); + } } const navigation = [ @@ -62,7 +78,7 @@ function classNames(...classes: Array) { } export default function AppShell({ loaderData }: Route.ComponentProps) { - const { user, provider } = loaderData; + const { user, provider, demoMode } = loaderData; const { pathname } = useLocation(); const matches = useMatches(); const { theme, toggle } = useTheme(); @@ -114,6 +130,20 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
+
+ + +
- +
+
+ + +
+ +
{provider !== "tailscale" && (
diff --git a/apps/arc-web/app/lib/demo-mode.server.ts b/apps/arc-web/app/lib/demo-mode.server.ts new file mode 100644 index 000000000..ef3ed2deb --- /dev/null +++ b/apps/arc-web/app/lib/demo-mode.server.ts @@ -0,0 +1,15 @@ +const COOKIE_NAME = "arc-demo"; + +/** Check whether demo mode is active for this request (cookie, then env var fallback). */ +export function isDemoMode(request: Request): boolean { + const cookies = request.headers.get("Cookie") ?? ""; + const match = cookies.match(/(?:^|;\s*)arc-demo=([^;]*)/); + if (match) return match[1] === "1"; + return process.env.ARC_DEMO === "1"; +} + +/** Build a Set-Cookie header value to persist the demo mode preference. */ +export function demoCookieHeader(enabled: boolean): string { + const value = enabled ? "1" : "0"; + return `${COOKIE_NAME}=${value}; Path=/; SameSite=Lax; Max-Age=${60 * 60 * 24 * 365}`; +} diff --git a/crates/arc-api/src/demo/mod.rs b/crates/arc-api/src/demo/mod.rs index fb65b9b01..52022ad80 100644 --- a/crates/arc-api/src/demo/mod.rs +++ b/crates/arc-api/src/demo/mod.rs @@ -1,5 +1,5 @@ //! Demo mode handlers that return static data for all API endpoints. -//! Used with `arc serve --demo` to showcase the UI without a real backend. +//! Activated per-request via the `X-Arc-Demo: 1` header to showcase the UI without a real backend. use std::sync::Arc; @@ -503,7 +503,7 @@ pub async fn create_session_stub( ) -> Response { ( StatusCode::CREATED, - Json(serde_json::json!({"id": "demo-session-new"})), + Json(serde_json::json!({"id": "demo-session-new", "created_at": "2026-03-06T16:00:00Z"})), ) .into_response() } @@ -2375,14 +2375,12 @@ mod sessions { SessionListItem { id: "s1".into(), title: "Add rate limiting to auth endpoints".into(), - repo: "api-server".into(), - time: "2h ago".into(), + created_at: "2026-03-06T14:30:00Z".into(), }, SessionListItem { id: "s2".into(), title: "Fix config parsing for nested values".into(), - repo: "cli-tools".into(), - time: "4h ago".into(), + created_at: "2026-03-06T12:30:00Z".into(), }, ], }, @@ -2392,20 +2390,17 @@ mod sessions { SessionListItem { id: "s3".into(), title: "Migrate to React Router v7".into(), - repo: "web-dashboard".into(), - time: "1d ago".into(), + created_at: "2026-03-05T10:00:00Z".into(), }, SessionListItem { id: "s4".into(), title: "Add dark mode toggle".into(), - repo: "web-dashboard".into(), - time: "1d ago".into(), + created_at: "2026-03-05T09:00:00Z".into(), }, SessionListItem { id: "s5".into(), title: "Update OpenAPI spec for v3".into(), - repo: "api-server".into(), - time: "1d ago".into(), + created_at: "2026-03-05T08:00:00Z".into(), }, ], }, @@ -2415,20 +2410,17 @@ mod sessions { SessionListItem { id: "s6".into(), title: "Terraform module for Redis cluster".into(), - repo: "infrastructure".into(), - time: "3d ago".into(), + created_at: "2026-03-03T15:00:00Z".into(), }, SessionListItem { id: "s7".into(), title: "Add pipeline event types".into(), - repo: "shared-types".into(), - time: "5d ago".into(), + created_at: "2026-03-01T11:00:00Z".into(), }, SessionListItem { id: "s8".into(), title: "Implement webhook retry logic".into(), - repo: "api-server".into(), - time: "6d ago".into(), + created_at: "2026-02-28T09:00:00Z".into(), }, ], }, @@ -2438,50 +2430,50 @@ mod sessions { pub fn detail(id: &str) -> Option { match id { "s1" => Some(SessionDetail { - id: "s1".into(), title: "Add rate limiting to auth endpoints".into(), repo: "api-server".into(), model: "Opus 4.6".into(), + id: "s1".into(), title: "Add rate limiting to auth endpoints".into(), model: "Opus 4.6".into(), created_at: "2026-03-06T14:30:00Z".into(), updated_at: "2026-03-06T15:45:00Z".into(), turns: vec![ - SessionTurn { kind: SessionTurnKind::User, content: Some("Add rate limiting to the auth endpoints. We're getting hit with brute force attempts on /api/auth/login and /api/auth/register. Use a sliding window approach with Redis, 10 requests per minute per IP.".into()), date: Some("Feb 28".into()), tools: vec![] }, - SessionTurn { kind: SessionTurnKind::Assistant, content: Some("I'll implement sliding window rate limiting using Redis. Let me first look at the existing auth routes and middleware setup.".into()), date: None, tools: vec![] }, - SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ + SessionTurn { kind: SessionTurnKind::User, content: Some("Add rate limiting to the auth endpoints. We're getting hit with brute force attempts on /api/auth/login and /api/auth/register. Use a sliding window approach with Redis, 10 requests per minute per IP.".into()), created_at: Some("2026-02-28T10:00:00Z".into()), tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("I'll implement sliding window rate limiting using Redis. Let me first look at the existing auth routes and middleware setup.".into()), created_at: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Tool, content: None, created_at: None, tools: vec![ ToolUse { tool_name: "read_file".into(), args: r#"{ "path": "src/routes/auth.ts" }"#.into(), result: "import { Router } from \"express\";\nimport { loginHandler } from \"../handlers/auth\";\nimport { registerHandler } from \"../handlers/auth\";\n\nconst router = Router();\nrouter.post(\"/login\", loginHandler);\nrouter.post(\"/register\", registerHandler);\n\nexport default router;".into() }, ToolUse { tool_name: "read_file".into(), args: r#"{ "path": "src/middleware/index.ts" }"#.into(), result: "export { authMiddleware } from \"./auth\";\nexport { corsMiddleware } from \"./cors\";\nexport { loggingMiddleware } from \"./logging\";".into() }, ToolUse { tool_name: "search_files".into(), args: r#"{ "pattern": "redis", "path": "src/" }"#.into(), result: "src/lib/redis.ts:1: import Redis from \"ioredis\";\nsrc/lib/redis.ts:3: export const redis = new Redis(process.env.REDIS_URL);".into() }, ] }, - SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Good \u{2014} there's already a Redis client at `src/lib/redis.ts`. I'll create a rate limiting middleware using the sliding window algorithm and apply it to the auth routes.\n\nHere's the plan:\n1. Create `src/middleware/rate-limit.ts` with sliding window logic\n2. Apply it to `/login` and `/register` routes\n3. Return `429 Too Many Requests` when the limit is exceeded".into()), date: None, tools: vec![] }, - SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Good \u{2014} there's already a Redis client at `src/lib/redis.ts`. I'll create a rate limiting middleware using the sliding window algorithm and apply it to the auth routes.\n\nHere's the plan:\n1. Create `src/middleware/rate-limit.ts` with sliding window logic\n2. Apply it to `/login` and `/register` routes\n3. Return `429 Too Many Requests` when the limit is exceeded".into()), created_at: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Tool, content: None, created_at: None, tools: vec![ ToolUse { tool_name: "write_file".into(), args: r#"{ "path": "src/middleware/rate-limit.ts" }"#.into(), result: "File written: src/middleware/rate-limit.ts (47 lines)".into() }, ToolUse { tool_name: "edit_file".into(), args: r#"{ "path": "src/routes/auth.ts", "operations": [{ "type": "insert", "line": 3 }] }"#.into(), result: "Applied 1 edit to src/routes/auth.ts".into() }, ToolUse { tool_name: "edit_file".into(), args: r#"{ "path": "src/middleware/index.ts", "operations": [{ "type": "append" }] }"#.into(), result: "Applied 1 edit to src/middleware/index.ts".into() }, ] }, - SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Done. I've created the rate limiter and wired it up:\n\n- **`src/middleware/rate-limit.ts`** \u{2014} Sliding window rate limiter using Redis sorted sets. Each request adds a timestamped entry, expired entries are pruned, and the count is checked against the limit.\n- **`src/routes/auth.ts`** \u{2014} Applied `rateLimit({ windowMs: 60_000, max: 10 })` to both `/login` and `/register`.\n- **`src/middleware/index.ts`** \u{2014} Re-exported the new middleware.\n\nWhen the limit is exceeded, clients get a `429` response with a `Retry-After` header indicating seconds until the window resets.".into()), date: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Done. I've created the rate limiter and wired it up:\n\n- **`src/middleware/rate-limit.ts`** \u{2014} Sliding window rate limiter using Redis sorted sets. Each request adds a timestamped entry, expired entries are pruned, and the count is checked against the limit.\n- **`src/routes/auth.ts`** \u{2014} Applied `rateLimit({ windowMs: 60_000, max: 10 })` to both `/login` and `/register`.\n- **`src/middleware/index.ts`** \u{2014} Re-exported the new middleware.\n\nWhen the limit is exceeded, clients get a `429` response with a `Retry-After` header indicating seconds until the window resets.".into()), created_at: None, tools: vec![] }, ], }), "s2" => Some(SessionDetail { - id: "s2".into(), title: "Fix config parsing for nested values".into(), repo: "cli-tools".into(), model: "Sonnet 4.6".into(), + id: "s2".into(), title: "Fix config parsing for nested values".into(), model: "Sonnet 4.6".into(), created_at: "2026-03-06T12:30:00Z".into(), updated_at: "2026-03-06T13:15:00Z".into(), turns: vec![ - SessionTurn { kind: SessionTurnKind::User, content: Some("The CLI crashes when parsing nested TOML config values like [database.connection]. Can you debug and fix this?".into()), date: Some("Feb 28".into()), tools: vec![] }, - SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Let me look at the config parser to understand how nested keys are handled.".into()), date: None, tools: vec![] }, - SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ + SessionTurn { kind: SessionTurnKind::User, content: Some("The CLI crashes when parsing nested TOML config values like [database.connection]. Can you debug and fix this?".into()), created_at: Some("2026-02-28T10:00:00Z".into()), tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Let me look at the config parser to understand how nested keys are handled.".into()), created_at: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Tool, content: None, created_at: None, tools: vec![ ToolUse { tool_name: "read_file".into(), args: r#"{ "path": "src/config/parser.ts" }"#.into(), result: "export function parseConfig(raw: string): Record {\n const result: Record = {};\n for (const line of raw.split(\"\\n\")) {\n const match = line.match(/^(\\w+)\\s*=\\s*(.+)$/);\n if (match) result[match[1]] = match[2].trim();\n }\n return result;\n}".into() }, ] }, - SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Found the issue. The parser uses a flat `Record` and the regex `^(\\w+)` only matches simple keys \u{2014} it can't handle TOML section headers like `[database.connection]` or dotted keys like `database.host`.\n\nI'll rewrite the parser to support nested sections and dotted keys, producing a properly nested object.".into()), date: None, tools: vec![] }, - SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Found the issue. The parser uses a flat `Record` and the regex `^(\\w+)` only matches simple keys \u{2014} it can't handle TOML section headers like `[database.connection]` or dotted keys like `database.host`.\n\nI'll rewrite the parser to support nested sections and dotted keys, producing a properly nested object.".into()), created_at: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Tool, content: None, created_at: None, tools: vec![ ToolUse { tool_name: "write_file".into(), args: r#"{ "path": "src/config/parser.ts" }"#.into(), result: "File written: src/config/parser.ts (62 lines)".into() }, ToolUse { tool_name: "run_command".into(), args: r#"{ "command": "npm test -- --grep 'config parser'" }"#.into(), result: "PASS src/config/__tests__/parser.test.ts\n config parser\n \u{2713} parses flat keys (2ms)\n \u{2713} parses section headers (1ms)\n \u{2713} parses dotted keys (1ms)\n \u{2713} handles nested sections (1ms)\n\nTest Suites: 1 passed\nTests: 4 passed".into() }, ] }, - SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Fixed. The parser now:\n\n1. Tracks the current section header (e.g. `[database.connection]`)\n2. Constructs nested objects from dotted paths\n3. Handles both `key = value` within sections and bare `dotted.key = value` syntax\n\nAll 4 tests pass, including the new nested section case.".into()), date: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("Fixed. The parser now:\n\n1. Tracks the current section header (e.g. `[database.connection]`)\n2. Constructs nested objects from dotted paths\n3. Handles both `key = value` within sections and bare `dotted.key = value` syntax\n\nAll 4 tests pass, including the new nested section case.".into()), created_at: None, tools: vec![] }, ], }), "s3" => Some(SessionDetail { - id: "s3".into(), title: "Migrate to React Router v7".into(), repo: "web-dashboard".into(), model: "Opus 4.6".into(), + id: "s3".into(), title: "Migrate to React Router v7".into(), model: "Opus 4.6".into(), created_at: "2026-03-05T10:00:00Z".into(), updated_at: "2026-03-05T11:30:00Z".into(), turns: vec![ - SessionTurn { kind: SessionTurnKind::User, content: Some("Help me migrate our app from React Router v6 to v7. We're using createBrowserRouter with data loaders.".into()), date: Some("Feb 26".into()), tools: vec![] }, - SessionTurn { kind: SessionTurnKind::Assistant, content: Some("I'll audit your current router setup and identify what needs to change for v7. Let me scan the codebase.".into()), date: None, tools: vec![] }, - SessionTurn { kind: SessionTurnKind::Tool, content: None, date: None, tools: vec![ + SessionTurn { kind: SessionTurnKind::User, content: Some("Help me migrate our app from React Router v6 to v7. We're using createBrowserRouter with data loaders.".into()), created_at: Some("2026-02-26T10:00:00Z".into()), tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("I'll audit your current router setup and identify what needs to change for v7. Let me scan the codebase.".into()), created_at: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Tool, content: None, created_at: None, tools: vec![ ToolUse { tool_name: "search_files".into(), args: r#"{ "pattern": "createBrowserRouter" }"#.into(), result: "src/main.tsx:5: import { createBrowserRouter } from \"react-router-dom\";\nsrc/main.tsx:8: const router = createBrowserRouter([".into() }, ToolUse { tool_name: "read_file".into(), args: r#"{ "path": "package.json" }"#.into(), result: "\"react-router-dom\": \"^6.22.0\"".into() }, ] }, - SessionTurn { kind: SessionTurnKind::Assistant, content: Some("You're on React Router 6.22. The migration to v7 involves:\n\n1. Replace `react-router-dom` with `react-router` (single package)\n2. Update `createBrowserRouter` import path\n3. Migrate data loaders to the new `Route.LoaderArgs` type\n4. Update any `useLoaderData` calls to be type-safe\n\nLet me make these changes.".into()), date: None, tools: vec![] }, + SessionTurn { kind: SessionTurnKind::Assistant, content: Some("You're on React Router 6.22. The migration to v7 involves:\n\n1. Replace `react-router-dom` with `react-router` (single package)\n2. Update `createBrowserRouter` import path\n3. Migrate data loaders to the new `Route.LoaderArgs` type\n4. Update any `useLoaderData` calls to be type-safe\n\nLet me make these changes.".into()), created_at: None, tools: vec![] }, ], }), _ => None, diff --git a/crates/arc-api/src/jwt_auth.rs b/crates/arc-api/src/jwt_auth.rs index dea0a9289..ad700f33c 100644 --- a/crates/arc-api/src/jwt_auth.rs +++ b/crates/arc-api/src/jwt_auth.rs @@ -44,7 +44,7 @@ pub fn jwt_validation() -> Validation { pub enum AuthMode { /// One or more strategies to try in order. Strategies(Vec), - /// Authentication is explicitly disabled (--demo flag only). + /// Authentication is explicitly disabled (used for demo requests via `X-Arc-Demo: 1` header). Disabled, } diff --git a/crates/arc-api/src/serve.rs b/crates/arc-api/src/serve.rs index cc8590219..2e334b88c 100644 --- a/crates/arc-api/src/serve.rs +++ b/crates/arc-api/src/serve.rs @@ -10,7 +10,7 @@ use tracing::{info, warn}; use clap::Args; use crate::jwt_auth::{AuthMode, AuthStrategy}; -use crate::server::{build_router, create_app_state_with_options}; +use crate::server::build_router; use crate::server_config::ServerConfig; use crate::tls::ClientAuth; use arc_workflows::cli::backend::AgentApiBackend; @@ -44,10 +44,6 @@ pub struct ServeArgs { #[arg(long, value_enum)] pub sandbox: Option, - /// Serve static demo data (disables auth, read-only) - #[arg(long)] - pub demo: bool, - /// Maximum number of concurrent run executions #[arg(long)] pub max_concurrent_runs: Option, @@ -124,14 +120,10 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: let (auth_mode, client_auth, max_concurrent_runs) = { let cfg = shared_config.read().expect("config lock poisoned"); - let auth_mode = if args.demo { - crate::jwt_auth::AuthMode::Disabled - } else { - crate::jwt_auth::resolve_auth_mode( - &cfg.api, - cfg.web.auth.allowed_usernames.clone(), - ) - }; + let auth_mode = crate::jwt_auth::resolve_auth_mode( + &cfg.api, + cfg.web.auth.allowed_usernames.clone(), + ); let client_auth = cfg .api .tls @@ -144,7 +136,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: (auth_mode, client_auth, max_concurrent_runs) }; - let state = create_app_state_with_options(db, factory, dry_run_mode, args.demo, max_concurrent_runs); + let state = crate::server::create_app_state_with_options(db, factory, dry_run_mode, max_concurrent_runs); crate::server::spawn_scheduler(Arc::clone(&state)); let router = build_router(state, auth_mode); @@ -164,43 +156,41 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: eprintln!("{}", styles.dim.apply_to("(dry-run mode)")); } - // Spawn config polling task (skip in demo mode) - if !args.demo { - let config_for_poll = Arc::clone(&shared_config); - let config_path_for_poll = config_path.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(5)); - interval.tick().await; // skip first immediate tick - loop { - interval.tick().await; - match crate::server_config::load_server_config(config_path_for_poll.as_deref()) { - Ok(new_config) => { - let changed = { - let cfg = config_for_poll.read().expect("config lock poisoned"); - *cfg != new_config - }; - if changed { - let mut cfg = config_for_poll.write().expect("config lock poisoned"); - *cfg = new_config; - info!("Server config reloaded"); - } - } - Err(e) => { - warn!("Failed to reload server config, keeping previous: {e}"); + // Spawn config polling task + let config_for_poll = Arc::clone(&shared_config); + let config_path_for_poll = config_path.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(5)); + interval.tick().await; // skip first immediate tick + loop { + interval.tick().await; + match crate::server_config::load_server_config(config_path_for_poll.as_deref()) { + Ok(new_config) => { + let changed = { + let cfg = config_for_poll.read().expect("config lock poisoned"); + *cfg != new_config + }; + if changed { + let mut cfg = config_for_poll.write().expect("config lock poisoned"); + *cfg = new_config; + info!("Server config reloaded"); } } + Err(e) => { + warn!("Failed to reload server config, keeping previous: {e}"); + } } - }); - } + } + }); - // Branch: TLS or plain HTTP (demo mode always uses plain HTTP) + // Branch: TLS or plain HTTP let tls_config = shared_config .read() .expect("config lock poisoned") .api .tls .clone(); - if let (false, Some(ref tls_config)) = (args.demo, &tls_config) { + if let Some(ref tls_config) = tls_config { let client_auth = client_auth.unwrap(); let rustls_config = crate::tls::build_rustls_config(tls_config, client_auth); diff --git a/crates/arc-api/src/server.rs b/crates/arc-api/src/server.rs index b8a0d6c7c..1774dc534 100644 --- a/crates/arc-api/src/server.rs +++ b/crates/arc-api/src/server.rs @@ -11,6 +11,7 @@ use axum::{Json, Router}; use tokio::sync::broadcast; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::StreamExt; +use tower::ServiceExt; use tracing::{error, info}; @@ -100,7 +101,6 @@ pub struct AppState { aggregate_usage: Mutex, registry_factory: Box) -> HandlerRegistry + Send + Sync>, dry_run: bool, - pub is_demo: bool, pub db: sqlx::SqlitePool, max_concurrent_runs: usize, scheduler_notify: tokio::sync::Notify, @@ -108,142 +108,173 @@ pub struct AppState { } /// Build the axum Router with all run endpoints. +/// +/// Both a demo router and a real router are constructed. Incoming requests +/// with the `X-Arc-Demo: 1` header are dispatched to the demo router; +/// all other requests go to the real router. pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { - let is_demo = state.is_demo; - - let mut router = Router::new() + let common = Router::new() .route("/", get(root)) .route("/health", get(health)) .route("/openapi.json", get(openapi_spec)) .route("/user", get(get_user)); - if is_demo { - router = router - .route( - "/runs", - get(crate::demo::list_runs).post(crate::demo::start_run_stub), - ) - .route("/runs/{id}", get(crate::demo::get_run_status)) - .route("/runs/{id}/questions", get(crate::demo::get_questions_stub)) - .route( - "/runs/{id}/questions/{qid}/answer", - post(crate::demo::answer_stub), - ) - .route("/runs/{id}/events", get(crate::demo::run_events_stub)) - .route("/runs/{id}/checkpoint", get(crate::demo::checkpoint_stub)) - .route("/runs/{id}/context", get(crate::demo::context_stub)) - .route("/runs/{id}/cancel", post(crate::demo::cancel_stub)) - .route("/runs/{id}/graph", get(crate::demo::get_run_graph)) - .route("/runs/{id}/retro", get(crate::demo::get_run_retro)) - .route("/runs/{id}/stages", get(crate::demo::get_run_stages)) - .route( - "/runs/{id}/stages/{stageId}/turns", - get(crate::demo::get_stage_turns), - ) - .route("/runs/{id}/compare", get(crate::demo::get_run_compare)) - .route("/runs/{id}/usage", get(crate::demo::get_run_usage)) - .route( - "/runs/{id}/verifications", - get(crate::demo::get_run_verifications), - ) - .route( - "/runs/{id}/configuration", - get(crate::demo::get_run_configuration), - ) - .route("/runs/{id}/steer", post(crate::demo::steer_run_stub)) - .route( - "/runs/{id}/preview", - post(crate::demo::generate_preview_url_stub), - ) - .route("/workflows", get(crate::demo::list_workflows)) - .route("/workflows/{name}", get(crate::demo::get_workflow)) - .route( - "/workflows/{name}/runs", - get(crate::demo::list_workflow_runs).post(crate::demo::trigger_workflow_run_stub), - ) - .route("/verifications", get(crate::demo::list_verifications)) - .route( - "/verifications/{slug}", - get(crate::demo::get_verification_detail), - ) - .route("/retros", get(crate::demo::list_retros)) - .route( - "/sessions", - get(crate::demo::list_sessions).post(crate::demo::create_session_stub), - ) - .route("/sessions/{id}", get(crate::demo::get_session)) - .route( - "/sessions/{id}/messages", - post(crate::demo::send_message_stub), - ) - .route( - "/sessions/{id}/events", - get(crate::demo::session_events_stub), - ) - .route( - "/insights/queries", - get(crate::demo::list_saved_queries).post(crate::demo::save_query_stub), - ) - .route( - "/insights/queries/{id}", - put(crate::demo::update_query_stub).delete(crate::demo::delete_query_stub), - ) - .route("/insights/execute", post(crate::demo::execute_query_stub)) - .route("/insights/history", get(crate::demo::list_query_history)) - .route("/settings", get(crate::demo::get_settings)) - .route("/projects", get(crate::demo::list_projects)) - .route("/projects/{id}/branches", get(crate::demo::list_branches)) - .route("/usage", get(crate::demo::get_aggregate_usage)); - } else { - router = router - .route("/runs", get(list_runs).post(start_run)) - .route("/runs/{id}", get(get_run_status)) - .route("/runs/{id}/questions", get(get_questions)) - .route("/runs/{id}/questions/{qid}/answer", post(submit_answer)) - .route("/runs/{id}/events", get(get_events)) - .route("/runs/{id}/checkpoint", get(get_checkpoint)) - .route("/runs/{id}/context", get(get_context)) - .route("/runs/{id}/cancel", post(cancel_run)) - .route("/runs/{id}/graph", get(get_graph)) - .route("/runs/{id}/retro", get(get_retro)) - .route("/runs/{id}/stages", get(not_implemented)) - .route("/runs/{id}/stages/{stageId}/turns", get(not_implemented)) - .route("/runs/{id}/compare", get(not_implemented)) - .route("/runs/{id}/usage", get(not_implemented)) - .route("/runs/{id}/verifications", get(not_implemented)) - .route("/runs/{id}/configuration", get(not_implemented)) - .route("/runs/{id}/steer", post(not_implemented)) - .route("/runs/{id}/preview", post(not_implemented)) - .route("/workflows", get(not_implemented)) - .route("/workflows/{name}", get(not_implemented)) - .route( - "/workflows/{name}/runs", - get(not_implemented).post(not_implemented), - ) - .route("/verifications", get(not_implemented)) - .route("/verifications/{slug}", get(not_implemented)) - .route("/retros", get(not_implemented)) - .route("/sessions", get(not_implemented).post(not_implemented)) - .route("/sessions/{id}", get(not_implemented)) - .route("/sessions/{id}/messages", post(not_implemented)) - .route("/sessions/{id}/events", get(not_implemented)) - .route( - "/insights/queries", - get(not_implemented).post(not_implemented), - ) - .route( - "/insights/queries/{id}", - put(not_implemented).delete(not_implemented), - ) - .route("/insights/execute", post(not_implemented)) - .route("/insights/history", get(not_implemented)) - .route("/settings", get(not_implemented)) - .route("/projects", get(not_implemented)) - .route("/projects/{id}/branches", get(not_implemented)) - .route("/usage", get(get_aggregate_usage)); - } + let demo_router = common + .clone() + .merge(demo_routes()) + .layer(axum::Extension(AuthMode::Disabled)) + .with_state(state.clone()); - router.layer(axum::Extension(auth_mode)).with_state(state) + let real_router = common + .merge(real_routes()) + .layer(axum::Extension(auth_mode)) + .with_state(state); + + let dispatch = tower::service_fn(move |req: axum::extract::Request| { + let demo = demo_router.clone(); + let real = real_router.clone(); + async move { + if req + .headers() + .get("x-arc-demo") + .map_or(false, |v| v == "1") + { + demo.oneshot(req).await + } else { + real.oneshot(req).await + } + } + }); + + Router::new().fallback_service(dispatch) +} + +fn demo_routes() -> Router> { + Router::new() + .route( + "/runs", + get(crate::demo::list_runs).post(crate::demo::start_run_stub), + ) + .route("/runs/{id}", get(crate::demo::get_run_status)) + .route("/runs/{id}/questions", get(crate::demo::get_questions_stub)) + .route( + "/runs/{id}/questions/{qid}/answer", + post(crate::demo::answer_stub), + ) + .route("/runs/{id}/events", get(crate::demo::run_events_stub)) + .route("/runs/{id}/checkpoint", get(crate::demo::checkpoint_stub)) + .route("/runs/{id}/context", get(crate::demo::context_stub)) + .route("/runs/{id}/cancel", post(crate::demo::cancel_stub)) + .route("/runs/{id}/graph", get(crate::demo::get_run_graph)) + .route("/runs/{id}/retro", get(crate::demo::get_run_retro)) + .route("/runs/{id}/stages", get(crate::demo::get_run_stages)) + .route( + "/runs/{id}/stages/{stageId}/turns", + get(crate::demo::get_stage_turns), + ) + .route("/runs/{id}/compare", get(crate::demo::get_run_compare)) + .route("/runs/{id}/usage", get(crate::demo::get_run_usage)) + .route( + "/runs/{id}/verifications", + get(crate::demo::get_run_verifications), + ) + .route( + "/runs/{id}/configuration", + get(crate::demo::get_run_configuration), + ) + .route("/runs/{id}/steer", post(crate::demo::steer_run_stub)) + .route( + "/runs/{id}/preview", + post(crate::demo::generate_preview_url_stub), + ) + .route("/workflows", get(crate::demo::list_workflows)) + .route("/workflows/{name}", get(crate::demo::get_workflow)) + .route( + "/workflows/{name}/runs", + get(crate::demo::list_workflow_runs).post(crate::demo::trigger_workflow_run_stub), + ) + .route("/verifications", get(crate::demo::list_verifications)) + .route( + "/verifications/{slug}", + get(crate::demo::get_verification_detail), + ) + .route("/retros", get(crate::demo::list_retros)) + .route( + "/sessions", + get(crate::demo::list_sessions).post(crate::demo::create_session_stub), + ) + .route("/sessions/{id}", get(crate::demo::get_session)) + .route( + "/sessions/{id}/messages", + post(crate::demo::send_message_stub), + ) + .route( + "/sessions/{id}/events", + get(crate::demo::session_events_stub), + ) + .route( + "/insights/queries", + get(crate::demo::list_saved_queries).post(crate::demo::save_query_stub), + ) + .route( + "/insights/queries/{id}", + put(crate::demo::update_query_stub).delete(crate::demo::delete_query_stub), + ) + .route("/insights/execute", post(crate::demo::execute_query_stub)) + .route("/insights/history", get(crate::demo::list_query_history)) + .route("/settings", get(crate::demo::get_settings)) + .route("/projects", get(crate::demo::list_projects)) + .route("/projects/{id}/branches", get(crate::demo::list_branches)) + .route("/usage", get(crate::demo::get_aggregate_usage)) +} + +fn real_routes() -> Router> { + Router::new() + .route("/runs", get(list_runs).post(start_run)) + .route("/runs/{id}", get(get_run_status)) + .route("/runs/{id}/questions", get(get_questions)) + .route("/runs/{id}/questions/{qid}/answer", post(submit_answer)) + .route("/runs/{id}/events", get(get_events)) + .route("/runs/{id}/checkpoint", get(get_checkpoint)) + .route("/runs/{id}/context", get(get_context)) + .route("/runs/{id}/cancel", post(cancel_run)) + .route("/runs/{id}/graph", get(get_graph)) + .route("/runs/{id}/retro", get(get_retro)) + .route("/runs/{id}/stages", get(not_implemented)) + .route("/runs/{id}/stages/{stageId}/turns", get(not_implemented)) + .route("/runs/{id}/compare", get(not_implemented)) + .route("/runs/{id}/usage", get(not_implemented)) + .route("/runs/{id}/verifications", get(not_implemented)) + .route("/runs/{id}/configuration", get(not_implemented)) + .route("/runs/{id}/steer", post(not_implemented)) + .route("/runs/{id}/preview", post(not_implemented)) + .route("/workflows", get(not_implemented)) + .route("/workflows/{name}", get(not_implemented)) + .route( + "/workflows/{name}/runs", + get(not_implemented).post(not_implemented), + ) + .route("/verifications", get(not_implemented)) + .route("/verifications/{slug}", get(not_implemented)) + .route("/retros", get(not_implemented)) + .route("/sessions", get(not_implemented).post(not_implemented)) + .route("/sessions/{id}", get(not_implemented)) + .route("/sessions/{id}/messages", post(not_implemented)) + .route("/sessions/{id}/events", get(not_implemented)) + .route( + "/insights/queries", + get(not_implemented).post(not_implemented), + ) + .route( + "/insights/queries/{id}", + put(not_implemented).delete(not_implemented), + ) + .route("/insights/execute", post(not_implemented)) + .route("/insights/history", get(not_implemented)) + .route("/settings", get(not_implemented)) + .route("/projects", get(not_implemented)) + .route("/projects/{id}/branches", get(not_implemented)) + .route("/usage", get(get_aggregate_usage)) } async fn not_implemented() -> Response { @@ -310,15 +341,14 @@ pub fn create_app_state( db: sqlx::SqlitePool, registry_factory: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - create_app_state_with_options(db, registry_factory, false, false, 5) + create_app_state_with_options(db, registry_factory, false, 5) } -/// Create an `AppState` with the given database pool, registry factory, dry-run flag, and demo flag. +/// Create an `AppState` with the given database pool, registry factory, dry-run flag, and concurrency limit. pub fn create_app_state_with_options( db: sqlx::SqlitePool, registry_factory: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, dry_run: bool, - is_demo: bool, max_concurrent_runs: usize, ) -> Arc { Arc::new(AppState { @@ -326,7 +356,6 @@ pub fn create_app_state_with_options( aggregate_usage: Mutex::new(AggregateUsageTotals::default()), registry_factory: Box::new(registry_factory), dry_run, - is_demo, db, max_concurrent_runs, scheduler_notify: tokio::sync::Notify::new(), @@ -1615,7 +1644,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrency_limit_respected() { - let state = create_app_state_with_options(test_db().await, test_registry, false, false, 1); + let state = create_app_state_with_options(test_db().await, test_registry, false, 1); let app = test_app_with_scheduler(state); // Submit two runs with max_concurrent_runs=1 diff --git a/crates/arc-api/tests/pagination.rs b/crates/arc-api/tests/pagination.rs index 468110f24..f2052866e 100644 --- a/crates/arc-api/tests/pagination.rs +++ b/crates/arc-api/tests/pagination.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use arc_api::jwt_auth::AuthMode; -use arc_api::server::{build_router, create_app_state_with_options}; +use arc_api::server::{build_router, create_app_state}; use arc_workflows::handler::exit::ExitHandler; use arc_workflows::handler::start::StartHandler; use arc_workflows::handler::HandlerRegistry; @@ -29,6 +29,7 @@ async fn get_json(app: axum::Router, uri: &str) -> serde_json::Value { let req = Request::builder() .method("GET") .uri(uri) + .header("x-arc-demo", "1") .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); @@ -103,7 +104,7 @@ const ENDPOINTS: &[PaginatedEndpoint] = &[ #[tokio::test] async fn paginated_endpoints_return_correct_shape() { - let state = create_app_state_with_options(test_db().await, test_registry, false, true, 5); + let state = create_app_state(test_db().await, test_registry); let app = build_router(state, AuthMode::Disabled); for ep in ENDPOINTS { diff --git a/docs/administration/advanced-setup.mdx b/docs/administration/advanced-setup.mdx index 0d23d8ff0..57f65b6fd 100644 --- a/docs/administration/advanced-setup.mdx +++ b/docs/administration/advanced-setup.mdx @@ -78,7 +78,6 @@ Several `server.toml` settings can be overridden via `arc serve` flags: | `--sandbox` | — | Override default sandbox provider | | `--max-concurrent-runs` | `5` | Maximum concurrent run executions | | `--config` | `~/.arc/server.toml` | Path to server config file | -| `--demo` | — | Serve static demo data (disables auth, read-only) | | `--dry-run` | — | Execute with simulated LLM backend | CLI flags take precedence over `server.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order. diff --git a/docs/getting-started/quick-start.mdx b/docs/getting-started/quick-start.mdx index d946e47b3..dc916f28e 100644 --- a/docs/getting-started/quick-start.mdx +++ b/docs/getting-started/quick-start.mdx @@ -75,11 +75,7 @@ The API server exposes a REST API for launching and managing workflow runs: ./target/release/arc serve ``` -This starts the server on `http://localhost:3000`. To try it with demo data (no API keys required): - -```bash -./target/release/arc serve --demo -``` +This starts the server on `http://localhost:3000`. Demo mode is per-request — the web UI sends the `X-Arc-Demo: 1` header automatically when configured with `ARC_DEMO=1`. ## Start the web frontend diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 1d5f8d0b3..a51204716 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -79,11 +79,7 @@ API mode supports two authentication strategies, configurable in `server.toml`: ### Demo mode -```bash -arc serve --demo -``` - -Demo mode disables authentication and serves static mock data for all endpoints. It lets you explore the web UI without API keys or real workflow execution. +Demo mode is per-request: send the `X-Arc-Demo: 1` HTTP header to get static mock data with authentication disabled. The web UI sends this header automatically when configured with `ARC_DEMO=1`. This lets you explore the UI without API keys or real workflow execution. ## Web UI diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index c0aa06c18..912ed3a2a 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -207,7 +207,6 @@ Start the HTTP API server that exposes the [REST API](/api-reference) for launch ```bash arc serve arc serve --port 8080 --host 0.0.0.0 -arc serve --demo arc serve --sandbox daytona --max-concurrent-runs 4 ``` @@ -219,10 +218,11 @@ arc serve --sandbox daytona --max-concurrent-runs 4 | `--provider ` | Override default LLM provider | — | | `--dry-run` | Execute with simulated LLM backend | — | | `--sandbox ` | Sandbox for agent tools: `local`, `docker`, or `daytona` | — | -| `--demo` | Serve static demo data (disables auth, read-only) | — | | `--max-concurrent-runs ` | Maximum number of concurrent run executions | — | | `--config ` | Path to server config file | `~/.arc/server.toml` | +Demo mode is per-request: send the `X-Arc-Demo: 1` header to get static demo data with auth disabled. + If no LLM provider API keys are configured, the server automatically falls back to dry-run mode. --- diff --git a/entrypoint.ts b/entrypoint.ts index e953a3f35..e15ba3b56 100644 --- a/entrypoint.ts +++ b/entrypoint.ts @@ -7,7 +7,7 @@ type ServiceConfig = { const services: Record = { api: { - command: ["arc", "serve", "--demo", "--host", "0.0.0.0"], + command: ["arc", "serve", "--host", "0.0.0.0"], }, web: { command: ["bun", "run", "dev", "--host", "0.0.0.0"],