mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
cbc1444489
commit
b601df47e5
14 changed files with 312 additions and 244 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
15
apps/arc-web/app/lib/demo-mode.server.ts
Normal file
15
apps/arc-web/app/lib/demo-mode.server.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue