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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-06 07:30:23 -05:00
parent cbc1444489
commit b601df47e5
14 changed files with 312 additions and 244 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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<string | false | null | undefined>) {
}
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) {
</div>
<div className="hidden md:block">
<div className="ml-4 flex items-center gap-3 md:ml-6">
<Form method="POST" className="flex">
<input type="hidden" name="intent" value="toggle-demo" />
<button
type="submit"
className={classNames(
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
demoMode ? "text-teal-500" : "text-fg-muted",
)}
title={demoMode ? "Switch to live data" : "Switch to demo data"}
>
<BeakerIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle demo mode</span>
</button>
</Form>
<button
type="button"
onClick={toggle}
@ -211,15 +241,31 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
{user.email}
</div>
</div>
<button
type="button"
onClick={toggle}
className="ml-auto rounded-full p-1.5 text-fg-muted transition-colors hover:bg-overlay hover:text-fg"
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
<ThemeIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle theme</span>
</button>
<div className="ml-auto flex items-center gap-2">
<Form method="POST" className="flex">
<input type="hidden" name="intent" value="toggle-demo" />
<button
type="submit"
className={classNames(
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
demoMode ? "text-teal-500" : "text-fg-muted",
)}
title={demoMode ? "Switch to live data" : "Switch to demo data"}
>
<BeakerIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle demo mode</span>
</button>
</Form>
<button
type="button"
onClick={toggle}
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-overlay hover:text-fg"
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
<ThemeIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle theme</span>
</button>
</div>
</div>
{provider !== "tailscale" && (
<div className="mt-3 space-y-1 px-2">

View file

@ -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}`;
}

View file

@ -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<SessionDetail> {
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<string, string> {\n const result: Record<string, string> = {};\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<string, string>` 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<string, string>` 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,

View file

@ -44,7 +44,7 @@ pub fn jwt_validation() -> Validation {
pub enum AuthMode {
/// One or more strategies to try in order.
Strategies(Vec<AuthStrategy>),
/// Authentication is explicitly disabled (--demo flag only).
/// Authentication is explicitly disabled (used for demo requests via `X-Arc-Demo: 1` header).
Disabled,
}

View file

@ -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<SandboxProvider>,
/// 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<usize>,
@ -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);

View file

@ -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<AggregateUsageTotals>,
registry_factory: Box<dyn Fn(Arc<dyn Interviewer>) -> 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<AppState>, 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<Arc<AppState>> {
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<Arc<AppState>> {
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<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
) -> Arc<AppState> {
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<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
dry_run: bool,
is_demo: bool,
max_concurrent_runs: usize,
) -> Arc<AppState> {
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

View file

@ -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 {

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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 <PROVIDER>` | Override default LLM provider | — |
| `--dry-run` | Execute with simulated LLM backend | — |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, or `daytona` | — |
| `--demo` | Serve static demo data (disables auth, read-only) | — |
| `--max-concurrent-runs <N>` | Maximum number of concurrent run executions | — |
| `--config <PATH>` | 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.
---

View file

@ -7,7 +7,7 @@ type ServiceConfig = {
const services: Record<string, ServiceConfig> = {
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"],