diff --git a/deno.jsonc b/deno.jsonc index 2049e42d..c33e4362 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -25,6 +25,8 @@ "bundle:sim:sourcemap": "deno run -A scripts/bundle_simulator_ui.ts --sourcemap=external", "bundle:sim:web": "deno run -A scripts/bundle_simulator_ui.ts --platform=browser", "bundle:sim:web:sourcemap": "deno run -A scripts/bundle_simulator_ui.ts --platform=browser --sourcemap=external", + "serve:bot": "mkdir -p /tmp/gambit-bot-root && GAMBIT_BOT_ROOT=/tmp/gambit-bot-root deno run -A src/cli.ts serve src/decks/gambit-bot/PROMPT.md --bundle --port 8000", + "serve:bot:sandbox": "deno run -A scripts/serve_bot_sandbox.ts", "build_npm": "deno run -A scripts/build_npm.ts" }, "lint": { diff --git a/docs/external/reference/cli/commands/serve.md b/docs/external/reference/cli/commands/serve.md index 60683c04..ce605d6d 100644 --- a/docs/external/reference/cli/commands/serve.md +++ b/docs/external/reference/cli/commands/serve.md @@ -18,3 +18,7 @@ flags = [ +++ Starts the debug UI server (default at `http://localhost:8000/`). + +If no deck path is provided, Gambit creates a new workspace scaffold (root +`PROMPT.md`, `INTENT.md`, plus default scenario/grader decks) and opens the +simulator UI in workspace onboarding mode. diff --git a/scripts/serve_bot_sandbox.ts b/scripts/serve_bot_sandbox.ts new file mode 100644 index 00000000..94f53e17 --- /dev/null +++ b/scripts/serve_bot_sandbox.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env -S deno run -A + +import { copy, ensureDir } from "@std/fs"; +import * as path from "@std/path"; + +const DEFAULT_NUX_DEMO_DECK_RELATIVE = + "src/decks/demo/nux_from_scratch/root.deck.md"; + +function resolveSourceDeckPath(opts: { + repoRoot: string; + gambitPackageRoot: string; +}): string { + const override = Deno.env.get("GAMBIT_NUX_DEMO_DECK_PATH")?.trim(); + const fallback = path.resolve( + opts.gambitPackageRoot, + DEFAULT_NUX_DEMO_DECK_RELATIVE, + ); + if (!override) { + return fallback; + } + return path.isAbsolute(override) + ? override + : path.resolve(opts.repoRoot, override); +} + +async function prepareSandboxDeck(opts: { + sourceDeckPath: string; + sandboxRoot: string; +}): Promise { + const sourceDeckPath = path.resolve(opts.sourceDeckPath); + const sourceDir = path.dirname(sourceDeckPath); + const sourceInfo = await Deno.stat(sourceDeckPath); + if (!sourceInfo.isFile) { + throw new Error(`Demo deck path is not a file: ${sourceDeckPath}`); + } + + await Deno.remove(opts.sandboxRoot, { recursive: true }).catch(() => {}); + await ensureDir(opts.sandboxRoot); + await copy(sourceDir, opts.sandboxRoot, { overwrite: true }); + + const relativeDeckPath = path.relative(sourceDir, sourceDeckPath); + const sandboxDeckPath = path.join(opts.sandboxRoot, relativeDeckPath); + await Deno.stat(sandboxDeckPath); + return sandboxDeckPath; +} + +async function main(): Promise { + const moduleDir = path.dirname(path.fromFileUrl(import.meta.url)); + const repoRoot = path.resolve(moduleDir, "..", "..", ".."); + const gambitPackageRoot = path.resolve(repoRoot, "packages", "gambit"); + const sandboxRoot = Deno.env.get("GAMBIT_SANDBOX_ROOT")?.trim() || + "/tmp/gambit-bot-sandbox"; + const port = Deno.env.get("GAMBIT_SANDBOX_PORT")?.trim() || "8000"; + + const sourceDeckPath = resolveSourceDeckPath({ + repoRoot, + gambitPackageRoot, + }); + const sandboxDeckPath = await prepareSandboxDeck({ + sourceDeckPath, + sandboxRoot, + }); + Deno.env.set("GAMBIT_SIMULATOR_BUILD_BOT_ROOT", sandboxRoot); + + const cmd = new Deno.Command("deno", { + args: [ + "run", + "-A", + "src/cli.ts", + "serve", + sandboxDeckPath, + "--bundle", + "--port", + port, + ], + cwd: gambitPackageRoot, + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + }); + + const child = cmd.spawn(); + const status = await child.status; + if (!status.success) { + Deno.exit(status.code ?? 1); + } +} + +if (import.meta.main) { + await main(); +} diff --git a/simulator-ui/demo/gambit-build-tab-demo-timeline.ts b/simulator-ui/demo/gambit-build-tab-demo-timeline.ts index d74e5dc2..192b8555 100644 --- a/simulator-ui/demo/gambit-build-tab-demo-timeline.ts +++ b/simulator-ui/demo/gambit-build-tab-demo-timeline.ts @@ -2,6 +2,7 @@ import type { DemoTimelineStep } from "./gambit-ui-demo-timeline.ts"; export function buildTabDemoTimeline(opts: { userPrompts: Array; + scenarioLabels?: Array; }): DemoTimelineStep[] { const beatOpenBuild: DemoTimelineStep[] = [ { type: "wait-for", selector: '[data-testid="nav-build"]' }, @@ -113,11 +114,36 @@ export function buildTabDemoTimeline(opts: { { type: "screenshot", label: "04-build-recent-changes" }, ]; - const beatCheckTabs: DemoTimelineStep[] = [ + const beatCheckTabs: DemoTimelineStep[] = []; + beatCheckTabs.push( { type: "click", selector: '[data-testid="nav-test"]' }, { type: "wait-for", selector: '[data-testid="testbot-run"]' }, { type: "wait", ms: 400 }, { type: "screenshot", label: "04-test-tab" }, + ); + const scenarioLabels = (opts.scenarioLabels ?? []).filter((label) => + label.trim().length > 0 + ); + if (scenarioLabels.length > 0) { + scenarioLabels.forEach((label, index) => { + beatCheckTabs.push( + { type: "click", selector: ".gds-listbox-trigger" }, + { type: "wait-for", selector: ".gds-listbox-popover" }, + { type: "click", text: label }, + { type: "wait", ms: 200 }, + { type: "click", selector: '[data-testid="testbot-run"]' }, + { + type: "wait-for", + selector: '[data-testid="testbot-status"]', + text: "Completed", + timeoutMs: 180_000, + }, + { type: "wait", ms: 200 }, + { type: "screenshot", label: `04-test-run-${index + 1}` }, + ); + }); + } + beatCheckTabs.push( { type: "click", selector: '[data-testid="nav-grade"]' }, { type: "wait-for", text: "Run a grader" }, { type: "wait", ms: 400 }, @@ -126,7 +152,7 @@ export function buildTabDemoTimeline(opts: { { type: "wait-for", selector: '[data-testid="build-chat-input"]' }, { type: "wait", ms: 400 }, { type: "screenshot", label: "06-build-tab-return" }, - ]; + ); return [ ...beatOpenBuild, diff --git a/simulator-ui/src/BuildChatContext.tsx b/simulator-ui/src/BuildChatContext.tsx index 59d2eb40..694e191e 100644 --- a/simulator-ui/src/BuildChatContext.tsx +++ b/simulator-ui/src/BuildChatContext.tsx @@ -67,9 +67,13 @@ type BuildChatContextValue = { const BuildChatContext = createContext(null); export function BuildChatProvider( - props: { children: React.ReactNode }, + props: { + children: React.ReactNode; + workspaceId?: string | null; + onWorkspaceChange?: (workspaceId: string) => void; + }, ) { - const { children } = props; + const { children, workspaceId, onWorkspaceChange } = props; const [run, setRun] = useState({ id: "", status: "idle", @@ -92,8 +96,10 @@ export function BuildChatProvider( { runId: string; turn: number; text: string } | null >(null); - const refreshStatus = useCallback(async (opts?: { runId?: string }) => { - const query = opts?.runId ? `?runId=${encodeURIComponent(opts.runId)}` : ""; + const refreshStatus = useCallback(async (opts?: { workspaceId?: string }) => { + const query = opts?.workspaceId + ? `?workspaceId=${encodeURIComponent(opts.workspaceId)}` + : ""; const res = await fetch(`/api/build/status${query}`); const data = await res.json().catch(() => ({})) as { run?: BuildRun }; if (data.run) { @@ -107,11 +113,31 @@ export function BuildChatProvider( runIdRef.current = data.run.id; } } - }, []); + }, [onWorkspaceChange]); useEffect(() => { + if (workspaceId) { + runIdRef.current = workspaceId; + refreshStatus({ workspaceId }).catch(() => {}); + return; + } refreshStatus().catch(() => {}); - }, [refreshStatus]); + }, [refreshStatus, workspaceId]); + + useEffect(() => { + if (!workspaceId) return; + if (runIdRef.current === workspaceId) return; + runIdRef.current = workspaceId; + setRun((prev) => ({ + ...prev, + id: workspaceId, + })); + setChatError(null); + setStreamingAssistant(null); + setOptimisticUser(null); + setToolCallsOpen({}); + refreshStatus({ workspaceId }).catch(() => {}); + }, [refreshStatus, workspaceId]); useEffect(() => { const streamId = BUILD_STREAM_ID; @@ -182,17 +208,51 @@ export function BuildChatProvider( [run.traces], ); - const ensureRunId = useCallback(() => { + const ensureWorkspaceId = useCallback(async () => { + if (workspaceId) return workspaceId; if (runIdRef.current) return runIdRef.current; - const next = `build-ui-${crypto.randomUUID()}`; - runIdRef.current = next; - setRun((prev) => ({ ...prev, id: next })); - return next; - }, []); + try { + const res = await fetch("/api/workspace/new", { + method: "POST", + }); + const data = await res.json().catch(() => ({})) as { + workspaceId?: string; + }; + if (res.ok && typeof data.workspaceId === "string") { + const nextWorkspaceId = data.workspaceId; + runIdRef.current = nextWorkspaceId; + setRun((prev) => ({ ...prev, id: nextWorkspaceId })); + onWorkspaceChange?.(nextWorkspaceId); + return nextWorkspaceId; + } + } catch { + // ignore + } + const fallback = `workspace-${crypto.randomUUID()}`; + runIdRef.current = fallback; + setRun((prev) => ({ ...prev, id: fallback })); + return fallback; + }, [onWorkspaceChange, workspaceId]); const resetChat = useCallback(async () => { - const runId = runIdRef.current; - if (!runId) { + const res = await fetch("/api/workspace/new", { method: "POST" }).catch( + () => null, + ); + const data = res + ? await res.json().catch(() => ({})) as { workspaceId?: string } + : {}; + if (res && res.ok && typeof data.workspaceId === "string") { + runIdRef.current = data.workspaceId; + setRun({ + id: data.workspaceId, + status: "idle", + messages: [], + traces: [], + toolInserts: [], + }); + onWorkspaceChange?.(data.workspaceId); + } else { + runIdRef.current = ""; setRun({ id: "", status: "idle", @@ -200,42 +260,23 @@ export function BuildChatProvider( traces: [], toolInserts: [], }); - setChatDraft(""); - setChatError(null); - setStreamingAssistant(null); - setOptimisticUser(null); - setToolCallsOpen({}); - return; } - await fetch("/api/build/reset", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ runId }), - }).catch(() => {}); - runIdRef.current = ""; - setRun({ - id: "", - status: "idle", - messages: [], - traces: [], - toolInserts: [], - }); setChatDraft(""); setChatError(null); setStreamingAssistant(null); setOptimisticUser(null); setToolCallsOpen({}); - }, []); + }, [onWorkspaceChange]); const sendMessage = useCallback(async (message: string) => { - const runId = ensureRunId(); + const runId = await ensureWorkspaceId(); setChatSending(true); setChatError(null); try { const res = await fetch("/api/build/message", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ runId, message }), + body: JSON.stringify({ workspaceId: runId, message }), }); const data = await res.json().catch(() => ({})) as { run?: BuildRun; @@ -260,7 +301,7 @@ export function BuildChatProvider( } finally { setChatSending(false); } - }, [ensureRunId]); + }, [ensureWorkspaceId]); const loadChat = useCallback(async (runId: string) => { setChatSending(true); @@ -269,7 +310,7 @@ export function BuildChatProvider( const res = await fetch("/api/build/load", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ runId }), + body: JSON.stringify({ workspaceId: runId }), }); const data = await res.json().catch(() => ({})) as { run?: BuildRun; @@ -290,6 +331,9 @@ export function BuildChatProvider( if (typeof data.run.id === "string" && data.run.id) { runIdRef.current = data.run.id; } + if (typeof data.run.id === "string" && data.run.id) { + onWorkspaceChange?.(data.run.id); + } setChatDraft(""); setOptimisticUser(null); setStreamingAssistant(null); diff --git a/simulator-ui/src/BuildPage.tsx b/simulator-ui/src/BuildPage.tsx index d871b605..2a27df9e 100644 --- a/simulator-ui/src/BuildPage.tsx +++ b/simulator-ui/src/BuildPage.tsx @@ -7,7 +7,7 @@ import React, { useState, } from "react"; import { createPortal } from "react-dom"; -import { type ToolCallSummary } from "./utils.ts"; +import { type ToolCallSummary, workspaceOnboardingEnabled } from "./utils.ts"; import PageShell from "./gds/PageShell.tsx"; import PageGrid from "./gds/PageGrid.tsx"; import Panel from "./gds/Panel.tsx"; @@ -117,7 +117,8 @@ export default function BuildPage(props: { setFileListLoading(true); setFileListError(null); try { - const res = await fetch("/api/build/files"); + const query = run.id ? `?workspaceId=${encodeURIComponent(run.id)}` : ""; + const res = await fetch(`/api/build/files${query}`); const data = await res.json().catch(() => ({})) as { entries?: BuildFileEntry[]; error?: string; @@ -134,7 +135,7 @@ export default function BuildPage(props: { } finally { setFileListLoading(false); } - }, []); + }, [run.id]); useEffect(() => { refreshFileList().catch(() => {}); @@ -227,9 +228,13 @@ export default function BuildPage(props: { const fetchPreview = async () => { setFilePreview({ status: "loading" }); try { - const res = await fetch( - `/api/build/file?path=${encodeURIComponent(selectedPath)}`, - ); + const params = new URLSearchParams({ + path: selectedPath, + }); + if (run.id) { + params.set("workspaceId", run.id); + } + const res = await fetch(`/api/build/file?${params.toString()}`); const data = await res.json().catch(() => ({})) as { contents?: string; tooLarge?: boolean; @@ -377,6 +382,13 @@ export default function BuildPage(props: { className="flex-column gap-8 flex-1 build-files-panel" style={{ minHeight: 0 }} > + {workspaceOnboardingEnabled && ( +
+ Workspace scaffold created. Use the Build chat to refine + PROMPT.md,{" "} + INTENT.md, and the default scenario/grader decks. +
+ )} {fileListError &&
{fileListError}
}
diff --git a/simulator-ui/src/GradePage.tsx b/simulator-ui/src/GradePage.tsx index d2b31b56..635994f6 100644 --- a/simulator-ui/src/GradePage.tsx +++ b/simulator-ui/src/GradePage.tsx @@ -109,7 +109,10 @@ function GradePage( const loadCalibrateData = useCallback(async () => { try { setLoading(true); - const res = await fetch("/api/calibrate"); + const params = new URLSearchParams(); + if (activeSessionId) params.set("sessionId", activeSessionId); + const query = params.toString() ? `?${params.toString()}` : ""; + const res = await fetch(`/api/calibrate${query}`); if (!res.ok) throw new Error(res.statusText); const data = await res.json() as CalibrateResponse; const nextGraders = Array.isArray(data.graderDecks) @@ -145,7 +148,7 @@ function GradePage( } finally { setLoading(false); } - }, []); + }, [activeSessionId]); useEffect(() => { loadCalibrateData(); @@ -624,10 +627,9 @@ function GradePage( )} {graders.length === 0 && (
- No grader decks found. Add [[graders]] (or legacy - {" "} - [[graderDecks]]) to your deck front matter to surface - graders here. + No graders found in the workspace root deck. Add{" "} + [[graders]] to PROMPT.md{" "} + (prefer the Build tab) to enable grading.
)} {sessions.length > 0 && graders.length > 0 && ( diff --git a/simulator-ui/src/TestBotPage.tsx b/simulator-ui/src/TestBotPage.tsx index 95c0db77..babd66df 100644 --- a/simulator-ui/src/TestBotPage.tsx +++ b/simulator-ui/src/TestBotPage.tsx @@ -138,7 +138,7 @@ export default function TestBotPage(props: { text: string; } | null >(null); - const deckSchema = useHttpSchema(); + const deckSchema = useHttpSchema({ sessionId: activeSessionId }); const deckInputSchema = deckSchema.schemaResponse?.schema; const deckSchemaDefaults = deckSchema.schemaResponse?.defaults; const deckSchemaError = deckSchema.schemaResponse?.error ?? @@ -174,6 +174,7 @@ export default function TestBotPage(props: { const fetchTestBotConfig = async (deckId?: string) => { const params = new URLSearchParams(); if (deckId) params.set("deckPath", deckId); + if (activeSessionId) params.set("sessionId", activeSessionId); const query = params.toString() ? `?${params.toString()}` : ""; return fetch(`/api/test${query}`); }; @@ -238,7 +239,7 @@ export default function TestBotPage(props: { } catch (err) { console.error(err); } - }, [deckStorageKey]); + }, [activeSessionId, deckStorageKey]); useEffect(() => { loadTestBot(); @@ -465,6 +466,12 @@ export default function TestBotPage(props: { setBotInputValue(nextBotInput); }, [botInputSchema, botInputDirty, botInputDefaults]); + useEffect(() => { + if (run.status === "error" && run.error) { + console.error("[test-bot] run error (state)", run.error); + } + }, [run.error, run.status]); + const missingBotInput = useMemo(() => { if (!botInputSchema) return []; return findMissingRequiredFields(botInputSchema, botInputValue); @@ -721,6 +728,7 @@ export default function TestBotPage(props: { initFill: missingDeckInit.length > 0 ? { missing: missingDeckInit } : undefined, + sessionId: activeSessionId ?? undefined, }; const res = await fetch("/api/test/run", { method: "POST", @@ -745,9 +753,13 @@ export default function TestBotPage(props: { data.sessionPath, ); } + const errorMessage = typeof data.error === "string" + ? data.error + : res.statusText; + console.error("[test-bot] run error", errorMessage); setRun({ status: "error", - error: typeof data.error === "string" ? data.error : res.statusText, + error: errorMessage, initFill: data.initFill, messages: [], traces: [], @@ -774,7 +786,10 @@ export default function TestBotPage(props: { toolInserts: [], }); } - refreshStatus({ runId: data.run?.id }); + refreshStatus({ + runId: data.run?.id, + sessionId: activeSessionId ?? undefined, + }); } catch (err) { allowRunSessionNavRef.current = false; console.error(err); @@ -786,6 +801,7 @@ export default function TestBotPage(props: { refreshStatus, selectedDeckId, missingDeckInit, + activeSessionId, ]); const stopRun = useCallback(async () => { @@ -975,7 +991,7 @@ export default function TestBotPage(props: { const payload: Record = { message: "", runId: nextRunId, - sessionId: run.sessionId, + sessionId: run.sessionId ?? activeSessionId ?? undefined, botDeckPath: selectedDeckId ?? undefined, }; if (!run.sessionId) { @@ -1015,6 +1031,7 @@ export default function TestBotPage(props: { run.id, run.sessionId, selectedDeckId, + activeSessionId, ]); const handleSendChat = useCallback(async () => { @@ -1042,7 +1059,7 @@ export default function TestBotPage(props: { const payload: Record = { message, runId: nextRunId, - sessionId: run.sessionId, + sessionId: run.sessionId ?? activeSessionId ?? undefined, botDeckPath: selectedDeckId ?? undefined, }; if (!run.sessionId) { @@ -1082,6 +1099,7 @@ export default function TestBotPage(props: { run.sessionId, run.status, selectedDeckId, + activeSessionId, ]); return ( @@ -1121,11 +1139,9 @@ export default function TestBotPage(props: { )} {testDecks.length === 0 && (
- No deck-defined personas found. Add [[scenarios]] - {" "} - (or legacy{" "} - [[testDecks]]) to your deck front matter to drive - the Test Bot. + No scenarios found in the workspace root deck. Add{" "} + [[scenarios]] to PROMPT.md{" "} + (prefer the Build tab) to enable Test runs.
)} {botDescription && ( diff --git a/simulator-ui/src/WorkbenchDrawer.tsx b/simulator-ui/src/WorkbenchDrawer.tsx index ff38e34e..2b2f3a11 100644 --- a/simulator-ui/src/WorkbenchDrawer.tsx +++ b/simulator-ui/src/WorkbenchDrawer.tsx @@ -302,14 +302,21 @@ export default function WorkbenchDrawer(props: WorkbenchDrawerProps) { title: (
{chatHistory.length > 0 && ( - + )} Chat {runStatusLabel} diff --git a/simulator-ui/src/main.tsx b/simulator-ui/src/main.tsx index 20d33b41..af73d2ca 100644 --- a/simulator-ui/src/main.tsx +++ b/simulator-ui/src/main.tsx @@ -22,6 +22,7 @@ import { deckDisplayPath, deckLabel, deckPath, + DEFAULT_BUILD_PATH, DEFAULT_SESSION_PATH, DEFAULT_TEST_PATH, deriveInitialFromSchema, @@ -41,6 +42,7 @@ import { setDurableStreamOffset, SIMULATOR_STREAM_ID, toRelativePath, + workspaceIdFromWindow, } from "./utils.ts"; import type { FeedbackEntry, @@ -1057,7 +1059,11 @@ function App() { const [workbenchDrawerOpen, setWorkbenchDrawerOpen] = useState(true); const sessionsApi = useSessions(); const [testBotResetToken, setTestBotResetToken] = useState(0); - const activeSessionId = getSessionIdFromPath(path); + const pathSessionId = getSessionIdFromPath(path); + const pathRequestsNewSession = /^\/sessions\/new(?:\/|$)/.test(path); + const activeSessionId = pathRequestsNewSession + ? null + : pathSessionId ?? workspaceIdFromWindow; const [workbenchSessionDetail, setWorkbenchSessionDetail] = useState< SessionDetailResponse | null >(null); @@ -1069,6 +1075,7 @@ function App() { const workbenchSessionRetryRef = useRef>({}); const workbenchRefreshTimeoutRef = useRef(null); const activeSessionIdRef = useRef(activeSessionId); + const workspaceInitRef = useRef(false); useEffect(() => { const handler = () => setPath(normalizeAppPath(window.location.pathname)); @@ -1089,7 +1096,7 @@ function App() { ); if (!res.ok) { if (!shouldApply()) return; - if (res.status === 404 || res.status === 502) { + if (res.status === 404) { const attempts = workbenchSessionRetryRef.current[sessionId] ?? 0; if (attempts < 5) { workbenchSessionRetryRef.current[sessionId] = attempts + 1; @@ -1317,6 +1324,15 @@ function App() { [replacePath], ); + const handleWorkspaceChange = useCallback( + (workspaceId: string) => { + replacePath( + `${SESSIONS_BASE_PATH}/${encodeURIComponent(workspaceId)}/build`, + ); + }, + [replacePath], + ); + useEffect(() => { if (!buildTabEnabled && path === "/build") { replacePath(DOCS_PATH); @@ -1324,7 +1340,9 @@ function App() { }, [path, replacePath]); const isDocs = path === DOCS_PATH; - const isBuild = buildTabEnabled && path === "/build"; + const isBuild = buildTabEnabled && + (path === "/build" || path === DEFAULT_BUILD_PATH || + /^\/sessions\/[^/]+\/build$/.test(path)); const isTestBot = !isDocs && /\/test$/.test(path); const isGrade = !isDocs && (path.startsWith("/grade") || @@ -1338,9 +1356,42 @@ function App() { : isGrade ? "grade" : "debug"; + + useEffect(() => { + if (activeSessionId) return; + if (workspaceInitRef.current) return; + if ( + currentPage !== "build" && currentPage !== "test" && + currentPage !== "grade" + ) { + return; + } + workspaceInitRef.current = true; + fetch("/api/workspace/new", { method: "POST" }) + .then(async (res) => { + const data = await res.json().catch(() => ({})) as { + workspaceId?: string; + }; + if (!res.ok || typeof data.workspaceId !== "string") return; + const nextPath = currentPage === "test" + ? `${SESSIONS_BASE_PATH}/${encodeURIComponent(data.workspaceId)}/test` + : currentPage === "grade" + ? buildGradePath(data.workspaceId) + : `${SESSIONS_BASE_PATH}/${ + encodeURIComponent(data.workspaceId) + }/build`; + replacePath(nextPath); + }) + .finally(() => { + workspaceInitRef.current = false; + }); + }, [activeSessionId, currentPage, replacePath]); const testBotPath = activeSessionId ? `${SESSIONS_BASE_PATH}/${encodeURIComponent(activeSessionId)}/test` : DEFAULT_TEST_PATH; + const buildPath = activeSessionId + ? `${SESSIONS_BASE_PATH}/${encodeURIComponent(activeSessionId)}/build` + : DEFAULT_BUILD_PATH; const debugPath = activeSessionId ? `${SESSIONS_BASE_PATH}/${encodeURIComponent(activeSessionId)}/debug` : DEFAULT_SESSION_PATH; @@ -1353,6 +1404,8 @@ function App() { ? `${SESSIONS_BASE_PATH}/${encodeURIComponent(sessionId)}/test` : currentPage === "grade" ? buildGradePath(sessionId) + : currentPage === "build" + ? `${SESSIONS_BASE_PATH}/${encodeURIComponent(sessionId)}/build` : `${SESSIONS_BASE_PATH}/${encodeURIComponent(sessionId)}/debug`; navigate(nextPath); setSessionsDrawerOpen(false); @@ -1367,6 +1420,9 @@ function App() { }, [sessionsApi.refresh, sessionsDrawerOpen]); const deckSessions = useMemo(() => { + if (workspaceIdFromWindow) { + return sessionsApi.sessions; + } return sessionsApi.sessions.filter((session) => { if (!session) return false; if (typeof session.deck === "string") { @@ -1387,6 +1443,7 @@ function App() { deckDisplayPath, normalizedDeckPath, repoRootPath, + workspaceIdFromWindow, ]); const handleDeleteAll = useCallback(async () => { @@ -1407,149 +1464,152 @@ function App() { ); return ( - <> -
-
-
-
- -
- - navigate( - next === "docs" - ? DOCS_PATH - : next === "build" - ? "/build" - : next === "test" - ? testBotPath - : next === "grade" - ? gradePath - : debugPath, - )} - tabs={[ - { id: "docs", label: "Docs", testId: "nav-docs" }, - ...(buildTabEnabled - ? [{ id: "build", label: "Build", testId: "nav-build" }] - : []), - { id: "test", label: "Test", testId: "nav-test" }, - { id: "grade", label: "Grade", testId: "nav-grade" }, - { id: "debug", label: "Debug", testId: "nav-debug" }, - ]} - /> -
- - {deckLabel} - -
-
-
- {navActions} + + <> +
+
+
+
+ + navigate( + next === "docs" + ? DOCS_PATH + : next === "build" + ? buildPath + : next === "test" + ? testBotPath + : next === "grade" + ? gradePath + : debugPath, + )} + tabs={[ + { id: "docs", label: "Docs", testId: "nav-docs" }, + ...(buildTabEnabled + ? [{ id: "build", label: "Build", testId: "nav-build" }] + : []), + { id: "test", label: "Test", testId: "nav-test" }, + { id: "grade", label: "Grade", testId: "nav-grade" }, + { id: "debug", label: "Debug", testId: "nav-debug" }, + ]} + /> +
+ + {deckLabel} + +
+
+
+ {navActions} + +
+
+
+
+ {currentPage === "docs" + ? + : currentPage === "build" + ? + : currentPage === "debug" + ? ( + setSessionsDrawerOpen(true)} + activeSessionId={activeSessionId} + /> + ) + : currentPage === "test" + ? ( + + ) + : ( + + )}
-
- {currentPage === "docs" - ? - : currentPage === "build" - ? - : currentPage === "debug" - ? ( - setSessionsDrawerOpen(true)} - activeSessionId={activeSessionId} - /> - ) - : currentPage === "test" - ? ( - - ) - : ( - - )} -
+ {workbenchDrawerOpen && ( + setWorkbenchDrawerOpen(false)} + loading={workbenchSessionDetailLoading} + error={workbenchSessionDetailError} + sessionId={activeSessionId} + sessionDetail={workbenchSessionDetail} + /> + )}
- {workbenchDrawerOpen && ( - setWorkbenchDrawerOpen(false)} - loading={workbenchSessionDetailLoading} - error={workbenchSessionDetailError} - sessionId={activeSessionId} - sessionDetail={workbenchSessionDetail} - /> - )} -
- setSessionsDrawerOpen(false)} - activeSessionId={activeSessionId} - bundleStamp={bundleStamp} - /> - + setSessionsDrawerOpen(false)} + activeSessionId={activeSessionId} + bundleStamp={bundleStamp} + /> + + ); } createRoot(document.getElementById("root")!).render( - - - + , ); diff --git a/simulator-ui/src/shared.tsx b/simulator-ui/src/shared.tsx index a635da11..0a2f9344 100644 --- a/simulator-ui/src/shared.tsx +++ b/simulator-ui/src/shared.tsx @@ -34,7 +34,7 @@ export type ConversationMessage = { respond?: RespondInfo; }; -export function useHttpSchema() { +export function useHttpSchema(opts?: { sessionId?: string | null }) { const [schemaResponse, setSchemaResponse] = useState( null, ); @@ -45,7 +45,10 @@ export function useHttpSchema() { setLoading(true); setError(null); try { - const res = await fetch("/schema"); + const params = new URLSearchParams(); + if (opts?.sessionId) params.set("sessionId", opts.sessionId); + const query = params.toString() ? `?${params.toString()}` : ""; + const res = await fetch(`/schema${query}`); if (!res.ok) throw new Error(res.statusText); const data = await res.json() as SchemaResponse; setSchemaResponse(data); @@ -54,7 +57,7 @@ export function useHttpSchema() { } finally { setLoading(false); } - }, []); + }, [opts?.sessionId]); useEffect(() => { refresh(); diff --git a/simulator-ui/src/utils.ts b/simulator-ui/src/utils.ts index b7c379a7..fe8ba226 100644 --- a/simulator-ui/src/utils.ts +++ b/simulator-ui/src/utils.ts @@ -268,6 +268,7 @@ export const SESSIONS_BASE_PATH = "/sessions"; export const DOCS_PATH = "/docs"; export const DEFAULT_SESSION_PATH = `${SESSIONS_BASE_PATH}/new/debug`; export const DEFAULT_TEST_PATH = `${SESSIONS_BASE_PATH}/new/test`; +export const DEFAULT_BUILD_PATH = `${SESSIONS_BASE_PATH}/new/build`; export const GRADE_PATH_SUFFIX = "/grade"; export const buildGradePath = (sessionId: string) => `${SESSIONS_BASE_PATH}/${encodeURIComponent(sessionId)}${GRADE_PATH_SUFFIX}`; @@ -281,6 +282,13 @@ export const buildTabEnabled = Boolean( (window as unknown as { __GAMBIT_BUILD_TAB_ENABLED__?: boolean }) .__GAMBIT_BUILD_TAB_ENABLED__, ); +export const workspaceOnboardingEnabled = Boolean( + (window as unknown as { __GAMBIT_WORKSPACE_ONBOARDING__?: boolean }) + .__GAMBIT_WORKSPACE_ONBOARDING__, +); +export const workspaceIdFromWindow = ( + window as unknown as { __GAMBIT_WORKSPACE_ID__?: string | null } +).__GAMBIT_WORKSPACE_ID__ ?? null; export const chatAccordionEnabled = Boolean( (window as unknown as { __GAMBIT_CHAT_ACCORDION_ENABLED__?: boolean }) .__GAMBIT_CHAT_ACCORDION_ENABLED__, @@ -608,7 +616,7 @@ export function getSessionIdFromPath( : window.location.pathname; const normalizedTarget = target.replace(/\/+$/, ""); const canonical = normalizedTarget.match( - /^\/sessions\/([^/]+)(?:\/(debug|grade|test))?$/, + /^\/sessions\/([^/]+)(?:\/(debug|grade|test|build))?$/, ); if (canonical) { const id = canonical[1]; @@ -1116,6 +1124,12 @@ export function normalizeAppPath(input: string): string { } return DEFAULT_TEST_PATH; } + if (trimmed === "/build") { + if (window.location.pathname !== DEFAULT_BUILD_PATH) { + window.history.replaceState({}, "", DEFAULT_BUILD_PATH); + } + return DEFAULT_BUILD_PATH; + } if ( trimmed === "/debug" || trimmed === "/simulate" || trimmed === SESSIONS_BASE_PATH @@ -1125,7 +1139,7 @@ export function normalizeAppPath(input: string): string { } return DEFAULT_SESSION_PATH; } - if (/^\/sessions\/[^/]+\/(debug|test|grade)$/.test(trimmed)) { + if (/^\/sessions\/[^/]+\/(debug|test|grade|build)$/.test(trimmed)) { return trimmed; } if (/^\/sessions\/[^/]+\/grade/.test(trimmed)) { @@ -1140,7 +1154,8 @@ export function normalizeAppPath(input: string): string { } if ( trimmed.startsWith("/sessions/") && !trimmed.includes("/debug") && - trimmed !== DEFAULT_SESSION_PATH + !trimmed.includes("/test") && !trimmed.includes("/grade") && + !trimmed.includes("/build") && trimmed !== DEFAULT_SESSION_PATH ) { const remainder = trimmed.slice("/sessions/".length); if (remainder && remainder !== "new") { diff --git a/src/cli.ts b/src/cli.ts index 53e4ae85..80c993fd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -107,7 +107,7 @@ async function resolveCliVersion(): Promise { } function resolveBotDeckPath(): string { - const url = new URL("./decks/gambit-bot.deck.md", import.meta.url); + const url = new URL("./decks/gambit-bot/PROMPT.md", import.meta.url); if (url.protocol !== "file:") { throw new Error("Unable to resolve bot deck path."); } @@ -235,7 +235,7 @@ async function main() { if ( !deckPath && args.cmd !== "grade" && args.cmd !== "export" && - args.cmd !== "bot" + args.cmd !== "bot" && args.cmd !== "serve" ) { printUsage(); Deno.exit(1); @@ -699,7 +699,7 @@ async function main() { if (args.cmd === "serve") { await handleServeCommand({ - deckPath, + deckPath: deckPath || undefined, model: args.model, modelForce: args.modelForce, modelProvider: provider, diff --git a/src/cli_utils.ts b/src/cli_utils.ts index a1815b05..1c45c3ad 100644 --- a/src/cli_utils.ts +++ b/src/cli_utils.ts @@ -56,6 +56,10 @@ function findProjectRoot(startDir: string): string | undefined { return undefined; } +export function resolveProjectRoot(startDir: string): string | undefined { + return findProjectRoot(startDir); +} + export function defaultSessionRoot(deckPath: string): string { const resolvedDeckPath = path.resolve(deckPath); const deckDir = path.dirname(resolvedDeckPath); diff --git a/src/commands/serve.ts b/src/commands/serve.ts index dcb8abd2..2c98b6ae 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -1,12 +1,14 @@ import * as path from "@std/path"; +import { existsSync } from "@std/fs"; import { startWebSocketSimulator } from "../server.ts"; import type { ModelProvider } from "@bolt-foundry/gambit-core"; -import { parsePortValue } from "../cli_utils.ts"; +import { parsePortValue, resolveProjectRoot } from "../cli_utils.ts"; +import { createWorkspaceScaffold } from "../workspace.ts"; const logger = console; export async function handleServeCommand(opts: { - deckPath: string; + deckPath?: string; model?: string; modelForce?: string; modelProvider: ModelProvider; @@ -20,6 +22,40 @@ export async function handleServeCommand(opts: { platform?: string; responsesMode?: boolean; }) { + const cwd = Deno.cwd(); + const baseRoot = opts.deckPath ? resolveProjectRoot(cwd) ?? cwd : cwd; + const workspaceBaseDir = path.join(baseRoot, ".gambit", "workspaces"); + const sessionsDir = path.join(baseRoot, ".gambit", "sessions"); + let resolvedDeckPath = opts.deckPath?.trim(); + let workspaceConfig: + | { + id: string; + rootDeckPath: string; + rootDir: string; + onboarding?: boolean; + scaffoldEnabled?: boolean; + scaffoldRoot?: string; + } + | undefined; + if (!resolvedDeckPath) { + const localPrompt = path.join(cwd, "PROMPT.md"); + if (existsSync(localPrompt)) { + resolvedDeckPath = localPrompt; + } else { + const workspace = await createWorkspaceScaffold({ + baseDir: workspaceBaseDir, + }); + resolvedDeckPath = workspace.rootDeckPath; + workspaceConfig = { + id: workspace.id, + rootDeckPath: workspace.rootDeckPath, + rootDir: workspace.rootDir, + onboarding: true, + scaffoldEnabled: true, + scaffoldRoot: workspaceBaseDir, + }; + } + } const envMode = (Deno.env.get("GAMBIT_ENV") ?? Deno.env.get("NODE_ENV") ?? "") .toLowerCase(); const isDevEnv = envMode === "development" || envMode === "dev" || @@ -44,7 +80,7 @@ export async function handleServeCommand(opts: { } const startServer = () => startWebSocketSimulator({ - deckPath: opts.deckPath, + deckPath: resolvedDeckPath ?? opts.deckPath ?? "", model: opts.model, modelForce: opts.modelForce, modelProvider: opts.modelProvider, @@ -52,6 +88,8 @@ export async function handleServeCommand(opts: { contextProvided: opts.contextProvided, port, verbose: opts.verbose, + sessionDir: workspaceConfig ? sessionsDir : undefined, + workspace: workspaceConfig, autoBundle, forceBundle, sourceMap, @@ -67,7 +105,7 @@ export async function handleServeCommand(opts: { const watchTargets = Array.from( new Set([ - path.dirname(opts.deckPath), + resolvedDeckPath ? path.dirname(resolvedDeckPath) : path.resolve("."), path.resolve("src"), ]), ).filter((p) => { diff --git a/src/decks/actions/bot_deck_review.deck.md b/src/decks/actions/bot_deck_review.deck.md new file mode 100644 index 00000000..635c42eb --- /dev/null +++ b/src/decks/actions/bot_deck_review.deck.md @@ -0,0 +1,22 @@ ++++ +label = "bot_deck_review" +modelParams = { model = "openai/gpt-4o-mini", temperature = 0 } +contextSchema = "./schemas/gambit_bot_review_input.zod.ts" +responseSchema = "./schemas/gambit_bot_review_output.zod.ts" ++++ + +You are a review assistant for the Gambit Bot deck. Use the provided guide +content as the authoritative checklist. Compare it against the current deck +content and produce a concise, actionable review. + +Rules: + +- Focus on concrete, high-impact changes only. +- Prefer Deck Format v1.0 guidance and Product Command alignment. +- If the deck is missing a required step, call it out explicitly. +- Keep recommendations ordered by importance. +- If the caller provided a goal, tailor the review to that goal. + +Return the review using the response schema. + +![respond](gambit://snippets/respond.md) diff --git a/src/decks/actions/bot_deck_review/PROMPT.md b/src/decks/actions/bot_deck_review/PROMPT.md new file mode 100644 index 00000000..c9c0d5a7 --- /dev/null +++ b/src/decks/actions/bot_deck_review/PROMPT.md @@ -0,0 +1,22 @@ ++++ +label = "bot_deck_review" +modelParams = { model = "openai/gpt-4o-mini", temperature = 0 } +contextSchema = "../schemas/gambit_bot_review_input.zod.ts" +responseSchema = "../schemas/gambit_bot_review_output.zod.ts" ++++ + +You are a review assistant for the Gambit Bot deck. Use the provided guide +content as the authoritative checklist. Compare it against the current deck +content and produce a concise, actionable review. + +Rules: + +- Focus on concrete, high-impact changes only. +- Prefer Deck Format v1.0 guidance and Product Command alignment. +- If the deck is missing a required step, call it out explicitly. +- Keep recommendations ordered by importance. +- If the caller provided a goal, tailor the review to that goal. + +Return the review using the response schema. + +![respond](gambit://snippets/respond.md) diff --git a/src/decks/actions/bot_exists/PROMPT.md b/src/decks/actions/bot_exists/PROMPT.md new file mode 100644 index 00000000..f9c7d783 --- /dev/null +++ b/src/decks/actions/bot_exists/PROMPT.md @@ -0,0 +1,6 @@ ++++ +label = "bot_exists" +execute = "../bot_exists.deck.ts" ++++ + +Compute-only deck for checking path existence under the bot root. diff --git a/src/decks/actions/bot_mkdir/PROMPT.md b/src/decks/actions/bot_mkdir/PROMPT.md new file mode 100644 index 00000000..02325749 --- /dev/null +++ b/src/decks/actions/bot_mkdir/PROMPT.md @@ -0,0 +1,6 @@ ++++ +label = "bot_mkdir" +execute = "../bot_mkdir.deck.ts" ++++ + +Compute-only deck for creating directories under the bot root. diff --git a/src/decks/actions/bot_read/PROMPT.md b/src/decks/actions/bot_read/PROMPT.md new file mode 100644 index 00000000..7e750c71 --- /dev/null +++ b/src/decks/actions/bot_read/PROMPT.md @@ -0,0 +1,6 @@ ++++ +label = "bot_read" +execute = "../bot_read.deck.ts" ++++ + +Compute-only deck for reading files under the bot root. diff --git a/src/decks/actions/bot_write/PROMPT.md b/src/decks/actions/bot_write/PROMPT.md new file mode 100644 index 00000000..44a7c223 --- /dev/null +++ b/src/decks/actions/bot_write/PROMPT.md @@ -0,0 +1,6 @@ ++++ +label = "bot_write" +execute = "../bot_write.deck.ts" ++++ + +Compute-only deck for writing files under the bot root. diff --git a/src/decks/actions/schemas/gambit_bot_review_input.zod.ts b/src/decks/actions/schemas/gambit_bot_review_input.zod.ts new file mode 100644 index 00000000..bb5a3c88 --- /dev/null +++ b/src/decks/actions/schemas/gambit_bot_review_input.zod.ts @@ -0,0 +1,10 @@ +import { z } from "npm:zod"; + +export default z.object({ + deckPath: z.string().describe("Path to the deck being reviewed."), + deckContents: z.string().describe("Current contents of the deck."), + guidePath: z.string().describe("Path to the local review guide."), + guideContents: z.string().describe("Review guide content to follow."), + goal: z.string().describe("Optional review goal provided by the caller.") + .optional(), +}); diff --git a/src/decks/actions/schemas/gambit_bot_review_output.zod.ts b/src/decks/actions/schemas/gambit_bot_review_output.zod.ts new file mode 100644 index 00000000..92b45367 --- /dev/null +++ b/src/decks/actions/schemas/gambit_bot_review_output.zod.ts @@ -0,0 +1,17 @@ +import { z } from "npm:zod"; + +export default z.object({ + summary: z.string().describe( + "Short summary of the main gaps or opportunities.", + ), + recommendations: z.array( + z.object({ + title: z.string().describe("Short name for the recommendation."), + rationale: z.string().describe("Why this change matters."), + suggestedChange: z.string().describe("Concrete change to apply."), + }), + ).describe("Ordered list of the most important recommendations."), + followUps: z.array( + z.string().describe("Targeted question to resolve an open decision."), + ).optional(), +}); diff --git a/src/decks/gambit-bot.deck.md b/src/decks/gambit-bot.deck.md deleted file mode 100644 index 1580d7f2..00000000 --- a/src/decks/gambit-bot.deck.md +++ /dev/null @@ -1,117 +0,0 @@ -+++ -label = "gambit_bot" - -[modelParams] -model = ["ollama/hf.co/LiquidAI/LFM2-1.2B-Tool-GGUF:latest", "openrouter/openai/gpt-5.1-chat"] -temperature = 0.2 - -[[actionDecks]] -name = "bot_write" -path = "./actions/bot_write.deck.ts" -description = "Create or update a file under the bot root." - -[[actionDecks]] -name = "bot_read" -path = "./actions/bot_read.deck.ts" -description = "Read a file under the bot root." - -[[actionDecks]] -name = "bot_exists" -path = "./actions/bot_exists.deck.ts" -description = "Check whether a path exists under the bot root." - -[[actionDecks]] -name = "bot_mkdir" -path = "./actions/bot_mkdir.deck.ts" -description = "Create a directory under the bot root." - -[[testDecks]] -label = "Recipe selection on-ramp tester" -path = "./tests/recipe_selection.test.deck.md" -description = "Synthetic user that asks Gambit Bot to build a recipe selection chatbot." - -[[testDecks]] -label = "Recipe selection (no skip)" -path = "./tests/recipe_selection_no_skip.test.deck.md" -description = "Synthetic user that completes the question flow without skipping to building." - -[[testDecks]] -label = "Build tab demo prompt" -path = "./tests/build_tab_demo.test.deck.md" -description = "Synthetic user prompt for the build tab demo." - -[[testDecks]] -label = "NUX from scratch demo prompt" -path = "./tests/nux_from_scratch_demo.test.deck.md" -description = "Synthetic user prompt for the NUX from-scratch build demo." -+++ - -You are the Gambit bot assistant. - -Your job: help the user create or update Gambit deck files within the allowed -folder. You may only use the file tools on paths under the bot root; if a user -asks for changes outside that folder, refuse and explain the boundary. - -Process: - -1. If the user says nothing yet, start with a brief, friendly greeting and ask - for the purpose of what they want to build. Otherwise, ask clarifying - questions about the purpose until the user is satisfied enough to proceed. - You may suggest example prompts, success criteria, or constraints, but do not - require anything beyond purpose to continue. Always accept “skip to - building”. -2. If the user asks for external integrations, scope it down to a runnable local - MVP first (fixtures, stub inputs, local files) and ask if they want to defer - real integrations. -3. Draft a Deck Format 1.0 root deck folder in the bot root: `PROMPT.md`, - `INTENT.md`, and `POLICY.md`. `INTENT.md` must follow the required Product - Command headings. Keep `POLICY.md` short and curated. Always include - `[modelParams]` with a `model` entry in every deck you write (root, actions, - scenarios, graders) so the simulator can run it. -4. Create a known local fixture file (for example `fixtures/fixture.txt`) with a - few short lines and embedded numbers. This is the deterministic source for - the default scenario. -5. Create two LLM action decks under `actions/` as deck folders with `PROMPT.md` - and local Zod schemas. Each action deck must: - - Declare `contextSchema` and `responseSchema`. - - Include the respond snippet (gambit://snippets/respond.md) so it returns - structured output. - - Have a non-empty `description` in the root `[[actions]]` list. - - Use an internal compute action deck to read the fixture deterministically - (see next step). -6. Create an internal compute action deck (for example `actions/read_fixture/`) - that reads the fixture file and returns raw text plus deterministic counts - (word/line count). Use `execute` with a TypeScript module and declare - `contextSchema` + `responseSchema` in the deck’s `PROMPT.md`. The two LLM - action decks should call this internal action. -7. Create a scenario deck folder under `scenarios/` with `PROMPT.md` and local - schemas. The scenario must: - - Drive a flow that reads the fixture via an action. - - Ask for a short summary plus a small deterministic computation (word/line - count or sum of numbers). - - Force a meaningful choice between the two actions (the wrong action leads - to incorrect output). Ensure the scenario sets `acceptsUserTurns = true`. -8. Create a grader deck folder under `graders/` with `PROMPT.md` and local - schemas. It should evaluate outcome correctness (summary + computation), not - tool usage or traces. Include the respond snippet. -9. Wire the root `PROMPT.md` to include `[[actions]]`, `[[scenarios]]`, and - `[[graders]]` arrays pointing directly to each deck’s `PROMPT.md` path. -10. Write or update `PROMPT.md`, `INTENT.md`, and `POLICY.md` together so they - reflect the latest agreed purpose and constraints (plus the scenario/grader - entries). -11. After the scaffold exists, continue the normal deck editing loop - indefinitely based on user requests. - -Rules: - -- Keep responses short and direct. -- Prefer creating or updating deck files over long explanations. -- When writing a deck file, use Deck Format 1.0 (`PROMPT.md` in a folder) with - TOML front matter. Do not invent custom DSLs. -- When creating a new root deck, always create scenario and grader decks under - `./scenarios/` and `./graders/` and link them via `[[scenarios]]` and - `[[graders]]`. -- Use `bot_exists` when deciding whether to create a new file. -- Use `bot_read` before editing existing files. -- Use `bot_mkdir` before writing files into new subfolders. -- After writing files, summarize which files changed. diff --git a/src/decks/gambit-bot/INTENT.md b/src/decks/gambit-bot/INTENT.md new file mode 100644 index 00000000..9560665d --- /dev/null +++ b/src/decks/gambit-bot/INTENT.md @@ -0,0 +1,62 @@ +# Gambit Bot Intent + +## Purpose + +- Act as a product-commanded assistant that helps people author, test, and + iterate on Gambit decks quickly and reliably. +- Reduce the time from idea to a runnable Deck Format v1.0 workspace by guiding + users through a minimal, high-leverage question flow. + +## End State + +- Users can create a valid Deck Format v1.0 workspace via the bot without manual + cleanup. +- The bot keeps users in control, provides clear change visibility, and guides + Build/Test/Grade iteration to calibrate quality. +- Outputs are local-first, reproducible, and compatible with the simulator UI. + +## Constraints + +- `PROMPT.md` is the canonical entrypoint; INTENT/POLICY are guidance only. +- Use existing Gambit runtime and test-bot primitives; do not fork pipelines. +- Avoid introducing remote dependencies without explicit opt-in. + +## Tradeoffs + +- Prefer clarity and runnable scaffolds over exhaustive customization. +- Prefer short, opinionated guidance to reduce user decision fatigue. + +## Risk tolerance + +- Moderate: ship iterative improvements as long as core workflows stay stable. + +## Escalation conditions + +- The bot produces decks that fail Deck Format v1.0 validation or cannot run. +- Changes risk breaking Build/Test/Grade flows in the simulator UI. +- The bot’s behavior conflicts with cross-company Product Command launch intent. + +## Verification steps + +- Bot flow produces a valid `PROMPT.md`-anchored deck with scenarios and + graders. +- Generated decks run end-to-end in Build/Test/Grade without manual edits. +- Bot-driven workflows pass `bft precommit` checks. + +## Activation / revalidation + +- Activation: When the Gambit Bot is used as the primary Build on-ramp. +- End: After 1.0 rollout and the bot workflow is stable and documented. +- Revalidation: Major changes to Deck Format v1.0 or bot scope. + +## Appendix + +### Inputs + +- `memos/cross-company/projects/gambit-product-command-launch/INTENT.md` +- `memos/product/projects/gambit-bot-launch/INTENT.md` +- `memos/engineering/areas/product-engineering/INTENT.md` + +### Related + +- `packages/gambit/src/decks/guides/gambit-bot-review.md` diff --git a/src/decks/gambit-bot/POLICY.md b/src/decks/gambit-bot/POLICY.md new file mode 100644 index 00000000..63d58763 --- /dev/null +++ b/src/decks/gambit-bot/POLICY.md @@ -0,0 +1,22 @@ +# Gambit Bot Deck Policy + +## Non-negotiables + +- Stay local-first: do not introduce remote dependencies without explicit opt-in + and a clear explanation of implications. +- Keep `PROMPT.md` as the canonical deck entrypoint. +- Use Deck Format v1.0 (TOML frontmatter) with `[modelParams]` populated. +- Do not write outside the bot root; use the bot file tools. + +## Behavior expectations + +- Ask the minimum number of questions needed to produce a runnable deck. +- Prefer “scenario” language over “test” in user-facing text. +- Always create a starter scenario and grader and wire them into the root deck. + +## Safety & reliability + +- If a change would break Build/Test/Grade workflows, stop and ask for + confirmation. +- If a deck cannot run with the current model setup, highlight the issue and + offer a fallback. diff --git a/src/decks/gambit-bot/PROMPT.md b/src/decks/gambit-bot/PROMPT.md new file mode 100644 index 00000000..3a20238b --- /dev/null +++ b/src/decks/gambit-bot/PROMPT.md @@ -0,0 +1,112 @@ ++++ +label = "gambit_bot" + +[modelParams] +model = ["ollama/hf.co/LiquidAI/LFM2-1.2B-Tool-GGUF:latest", "openrouter/openai/gpt-5.1-chat"] +temperature = 0.2 + +[[actions]] +name = "bot_write" +path = "../actions/bot_write/PROMPT.md" +description = "Create or update a file under the bot root." + +[[actions]] +name = "bot_read" +path = "../actions/bot_read/PROMPT.md" +description = "Read a file under the bot root." + +[[actions]] +name = "bot_exists" +path = "../actions/bot_exists/PROMPT.md" +description = "Check whether a path exists under the bot root." + +[[actions]] +name = "bot_mkdir" +path = "../actions/bot_mkdir/PROMPT.md" +description = "Create a directory under the bot root." + +[[actions]] +name = "bot_deck_review" +path = "../actions/bot_deck_review/PROMPT.md" +description = "Review the Gambit Bot deck against local guidance and propose improvements." + +[[scenarios]] +label = "Recipe selection on-ramp tester" +path = "../tests/recipe_selection/PROMPT.md" +description = "Synthetic user that asks Gambit Bot to build a recipe selection chatbot." + +[[scenarios]] +label = "Recipe selection (no skip)" +path = "../tests/recipe_selection_no_skip/PROMPT.md" +description = "Synthetic user that completes the question flow without skipping to building." + +[[scenarios]] +label = "Build tab demo prompt" +path = "../tests/build_tab_demo/PROMPT.md" +description = "Synthetic user prompt for the build tab demo." + +[[scenarios]] +label = "NUX from scratch demo prompt" +path = "../tests/nux_from_scratch_demo/PROMPT.md" +description = "Synthetic user prompt for the NUX from-scratch build demo." ++++ + +You are GambitBot, a product-commanded guide that helps people build AI agents +and assistants with the Gambit harness. Your job is to get users to a working +deck quickly, with the smallest number of high-leverage questions. + +Success means: the user ends with a runnable Deck Format v1.0 structure +(`PROMPT.md` entrypoint plus `INTENT.md`, optional `POLICY.md`), created via the +bot file tools, and the next steps are clear. + +Style: short, opinionated, and helpful. Ask only for the minimum info needed. +Prefer "scenario" language. Keep the system prompt stable and avoid dynamic +variables; put user-specific context in user turns or tool reads. + +If the first user message is empty, introduce yourself with a greeting and ask +what kind of agent they want to build. + +When the user confirms they want to build (e.g. "yes," "build it," "sure"), +immediately switch to file creation using the bot tools: + +- If the user's request already includes a clear purpose and target persona, + draft immediately using reasonable defaults and list assumptions in the + summary. +- Otherwise ask at most one clarifying question, then draft with defaults. +- Create required folders with `bot_mkdir` as needed. +- Write `INTENT.md` using this template: + - Purpose + - End State + - Constraints + - Tradeoffs + - Risk tolerance + - Escalation conditions + - Verification steps + - Activation / revalidation + - Appendix + - Inputs + - Related +- Write `PROMPT.md` using Deck Format v1.0 (TOML frontmatter) and include a + `[[scenarios]]` entry for a starter scenario if applicable. +- Always create a starter scenario file at `scenarios/first/PROMPT.md` and a + starter grader file at `graders/first/PROMPT.md` (use `bot_mkdir` as needed), + then reference them from the root `PROMPT.md` via `[[scenarios]]` / + `[[graders]]`. +- Always include `[modelParams]` with a concrete `model` in every deck you write + (`PROMPT.md`, actions, scenarios, graders) so the simulator can run it. +- If a `POLICY.md` is helpful, write a short one; otherwise omit. +- Summarize what you created and suggest the next step. + +If the user asks to review, improve, or update the Gambit Bot deck (or its +onboarding flow), follow this review flow before answering: + +1. Use `bot_read` to load + `packages/gambit/src/decks/guides/gambit-bot-review.md`. +2. Use `bot_read` to load `packages/gambit/src/decks/gambit-bot/PROMPT.md`. +3. Call `bot_deck_review` with the guide contents, deck contents, and the user's + stated goal. +4. Summarize the recommendations, then ask for confirmation before applying + changes. + +If the review guide or deck path does not exist under the bot root, skip the +review flow and proceed normally. diff --git a/src/decks/guides/gambit-bot-review.md b/src/decks/guides/gambit-bot-review.md new file mode 100644 index 00000000..5087a1a2 --- /dev/null +++ b/src/decks/guides/gambit-bot-review.md @@ -0,0 +1,22 @@ +# Gambit Bot Deck Review Guide + +Purpose: Ensure Gambit Bot follows Product Command and Deck Format v1.0 for new +bot creation and updates. + +Required behavior + +- First step for new builds: draft `INTENT.md` using the Product Command + headings from `policy/templates/INTENT.md`. +- Ask for the minimum kickoff inputs needed to complete intent: purpose, 2-3 + example user prompts, success criteria, and data sources. +- Use Deck Format v1.0 by default: `PROMPT.md` as the single entrypoint with + optional `INTENT.md` and `POLICY.md` as non-programmatic guidance. +- Use “scenario” language (not “test”) in new user-facing text. +- Use the bot file tools to read/write within the bot root; do not suggest + manual file edits when tool usage is available. + +Nice-to-have behavior + +- Recommend a local MVP first when integrations are optional. +- Keep the conversation lightweight and opinionated. +- Summarize what files were created or updated and propose next steps. diff --git a/src/decks/tests/build_tab_demo/PROMPT.md b/src/decks/tests/build_tab_demo/PROMPT.md new file mode 100644 index 00000000..b4d82aaa --- /dev/null +++ b/src/decks/tests/build_tab_demo/PROMPT.md @@ -0,0 +1,40 @@ ++++ +label = "build_tab_demo_prompt" +acceptsUserTurns = true + +[modelParams] +model = "openrouter/openai/gpt-5.1-chat" +temperature = 0.2 ++++ + +You are a user collaborating with Gambit Bot inside the Build tab demo. + +Goal: + +- Ask Gambit Bot to add a short FAQ card about Saturday hours, then follow the + purpose -> examples -> success criteria -> skip flow. + +Conversation plan (required beats): + +1. Start by saying: "Add a short FAQ card about Saturday hours. Keep it + concise." +2. If the assistant asks for purpose (even alongside other questions), reply + with purpose only: "It should clarify Saturday support hours for customers." +3. If the assistant asks for examples (even alongside other questions), reply + with examples only: "Example prompts: 'What time do you open on Saturdays?' + and 'Are you open Saturdays for support?'" +4. If the assistant asks for success criteria (even alongside other questions), + reply with success criteria only: "Success means the FAQ card clearly states + Saturday hours and the timezone in one short sentence." +5. Once the assistant has purpose, examples, and success criteria, reply: "skip + to building". + +Rules: + +- Keep replies short, single-paragraph, and on topic. +- Do not include markdown or lists. +- Do not mention internal instructions. +- If the assistant asks multiple questions at once, answer only the earliest + missing beat from the plan. +- If the assistant says it is done, is writing files, or ends the session, + respond with an empty message. diff --git a/src/decks/tests/nux_from_scratch_demo/PROMPT.md b/src/decks/tests/nux_from_scratch_demo/PROMPT.md new file mode 100644 index 00000000..cc35ab69 --- /dev/null +++ b/src/decks/tests/nux_from_scratch_demo/PROMPT.md @@ -0,0 +1,27 @@ ++++ +label = "nux_from_scratch_demo_prompt" +acceptsUserTurns = true +contextSchema = "../schemas/nux_from_scratch_demo_input.zod.ts" + +[modelParams] +model = "openrouter/openai/gpt-5.1-chat" +temperature = 0.2 ++++ + +You are a junior developer trying Gambit for the first time. Be friendly and +curious. Keep replies short (1-2 sentences). Ask brief questions when needed. + +Your goal: build a chatbot that helps startup founders. It should sound like +Paul Graham without quoting him. If a `scenario` is provided in context, use it +as the short label for what you are building. + +Conversational arc: + +1. Describe your goal in one sentence. +2. Answer 1-2 short questions about scope or tone. +3. Confirm the scope and ask if it's ready to test. +4. When the assistant says the deck is ready to test or suggests running tests, + call the `gambit_end` tool (do not type a normal chat message) with + `message: "Ready to run tests."`. + +![end](gambit://snippets/end.md) diff --git a/src/decks/tests/recipe_selection/PROMPT.md b/src/decks/tests/recipe_selection/PROMPT.md new file mode 100644 index 00000000..b75f59e8 --- /dev/null +++ b/src/decks/tests/recipe_selection/PROMPT.md @@ -0,0 +1,33 @@ ++++ +label = "recipe_selection_test_bot" +acceptsUserTurns = true +[modelParams] +model = "openai/gpt-4o-mini" +temperature = 0.2 ++++ + +You are a user trying to set up a recipe selection chatbot. + +Goals: + +- Ensure the bot asks a short set of kickoff questions (purpose, example + prompts, success criteria). +- If asked about integrations or data sources, prefer a local MVP first. +- Ask to "skip to building" once the basics are covered. + +Conversation plan: + +1. Start by saying you want a chatbot that helps people pick recipes. +2. If the bot asks for examples, provide two sample prompts: + - "I have chicken, spinach, and rice. What can I make in 30 minutes?" + - "Suggest a vegetarian dinner under $15 with leftovers." +3. If the bot asks for success criteria, say: + - "It should ask one clarifying question and then recommend 3 recipes with + short reasons." +4. If the bot asks about integrations (e.g., recipe APIs), say: + - "Let's start with a local MVP using a small hardcoded list." +5. After the bot summarizes or proposes a plan, reply: "skip to building". +6. End the conversation after it writes the deck files. + +If the assistant says goodbye or indicates the session is ending, respond with +an empty message to end the test run. diff --git a/src/decks/tests/recipe_selection_no_skip/PROMPT.md b/src/decks/tests/recipe_selection_no_skip/PROMPT.md new file mode 100644 index 00000000..4daea716 --- /dev/null +++ b/src/decks/tests/recipe_selection_no_skip/PROMPT.md @@ -0,0 +1,27 @@ ++++ +label = "recipe_selection_no_skip_test_bot" +acceptsUserTurns = true +[modelParams] +model = "openai/gpt-4o-mini" +temperature = 0.2 ++++ + +You are a user trying to set up a recipe selection chatbot. Do not say "skip to +building." Complete the question flow instead. + +Conversation plan: + +1. Start by saying you want a chatbot that helps people pick recipes. +2. If the bot asks for examples, provide two sample prompts: + - "I have chicken, spinach, and rice. What can I make in 30 minutes?" + - "Suggest a vegetarian dinner under $15 with leftovers." +3. If the bot asks for success criteria, say: + - "It should ask one clarifying question and then recommend 3 recipes with + short reasons." +4. If the bot asks about integrations (e.g., recipe APIs), say: + - "Let's start with a local MVP using a small hardcoded list." +5. If the bot asks whether to proceed or summarize, confirm and proceed. +6. End the conversation after it writes the deck files. + +If the assistant says goodbye or indicates the session is ending, respond with +an empty message to end the test run. diff --git a/src/server.test.ts b/src/server.test.ts index 0ee1cef8..6d835b29 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -217,61 +217,51 @@ Deno.test("build bot endpoint streams status and runs", async () => { }, }; - const prevFlag = Deno.env.get("GAMBIT_SIMULATOR_BUILD_TAB"); - Deno.env.set("GAMBIT_SIMULATOR_BUILD_TAB", "1"); - try { - const server = startWebSocketSimulator({ - deckPath, - modelProvider: provider, - port: 0, - }); - const port = (server.addr as Deno.NetAddr).port; + const server = startWebSocketSimulator({ + deckPath, + modelProvider: provider, + port: 0, + }); + const port = (server.addr as Deno.NetAddr).port; - const homepage = await fetch(`http://127.0.0.1:${port}/build`); - const html = await homepage.text(); - assert(html.includes("__GAMBIT_BUILD_TAB_ENABLED__")); + const homepage = await fetch(`http://127.0.0.1:${port}/build`); + const html = await homepage.text(); + assert(html.includes("__GAMBIT_BUILD_TAB_ENABLED__")); - const runId = "test-build-run"; - const res = await fetch(`http://127.0.0.1:${port}/api/build/message`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ runId, message: "" }), - }); - const body = await res.json().catch(() => ({})) as { - run?: { id?: string; status?: string }; - error?: string; + const runId = "test-build-run"; + const res = await fetch(`http://127.0.0.1:${port}/api/build/message`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ runId, message: "" }), + }); + const body = await res.json().catch(() => ({})) as { + run?: { id?: string; status?: string }; + error?: string; + }; + assertEquals(res.ok, true); + assertEquals(body.run?.id, runId); + + let status: unknown = null; + for (let i = 0; i < 20; i += 1) { + const sres = await fetch( + `http://127.0.0.1:${port}/api/build/status?runId=${ + encodeURIComponent(runId) + }`, + ); + const sb = await sres.json().catch(() => ({})) as { + run?: { status?: string; messages?: Array<{ content?: string }> }; }; - assertEquals(res.ok, true); - assertEquals(body.run?.id, runId); - - let status: unknown = null; - for (let i = 0; i < 20; i += 1) { - const sres = await fetch( - `http://127.0.0.1:${port}/api/build/status?runId=${ - encodeURIComponent(runId) - }`, - ); - const sb = await sres.json().catch(() => ({})) as { - run?: { status?: string; messages?: Array<{ content?: string }> }; - }; - status = sb.run?.status ?? null; - if (sb.run?.status === "completed") { - assert((sb.run.messages?.[0]?.content ?? "").length > 0); - break; - } - await new Promise((r) => setTimeout(r, 50)); - } - assertEquals(status, "completed"); - - await server.shutdown(); - await server.finished; - } finally { - if (prevFlag === undefined) { - Deno.env.delete("GAMBIT_SIMULATOR_BUILD_TAB"); - } else { - Deno.env.set("GAMBIT_SIMULATOR_BUILD_TAB", prevFlag); + status = sb.run?.status ?? null; + if (sb.run?.status === "completed") { + assert((sb.run.messages?.[0]?.content ?? "").length > 0); + break; } + await new Promise((r) => setTimeout(r, 50)); } + assertEquals(status, "completed"); + + await server.shutdown(); + await server.finished; }); Deno.test("simulator appends feedback log entries", async () => { diff --git a/src/server.ts b/src/server.ts index af2a6988..d20f614a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { sanitizeNumber } from "./test_bot.ts"; import { makeConsoleTracer } from "./trace.ts"; import { defaultSessionRoot } from "./cli_utils.ts"; import { loadDeck } from "@bolt-foundry/gambit-core"; +import { createWorkspaceScaffold } from "./workspace.ts"; import { appendDurableStreamEvent, handleDurableStreamRequest, @@ -717,6 +718,14 @@ export function startWebSocketSimulator(opts: { verbose?: boolean; signal?: AbortSignal; sessionDir?: string; + workspace?: { + id: string; + rootDeckPath: string; + rootDir: string; + onboarding?: boolean; + scaffoldEnabled?: boolean; + scaffoldRoot?: string; + }; autoBundle?: boolean; forceBundle?: boolean; sourceMap?: boolean; @@ -729,7 +738,13 @@ export function startWebSocketSimulator(opts: { (initialContext !== undefined); const consoleTracer = opts.verbose ? makeConsoleTracer() : undefined; let resolvedDeckPath = resolveDeckPath(opts.deckPath); - let buildBotRootCache: string | null = null; + const buildBotRootCache = new Map(); + const activeWorkspaceId = opts.workspace?.id ?? null; + const activeWorkspaceOnboarding = Boolean(opts.workspace?.onboarding); + const workspaceScaffoldEnabled = Boolean(opts.workspace?.scaffoldEnabled); + const workspaceScaffoldRoot = opts.workspace?.scaffoldRoot + ? path.resolve(opts.workspace.scaffoldRoot) + : null; const sessionsRoot = (() => { const base = opts.sessionDir ? path.resolve(opts.sessionDir) @@ -745,19 +760,26 @@ export function startWebSocketSimulator(opts: { } return base; })(); - const buildRunsRoot = (() => { - const dir = path.join(sessionsRoot, "build-runs"); - try { - Deno.mkdirSync(dir, { recursive: true }); - } catch (err) { - logger.warn( - `[sim] unable to ensure build runs directory ${dir}: ${ - err instanceof Error ? err.message : err - }`, - ); + const workspaceRoot = (() => { + const dir = workspaceScaffoldRoot ?? + path.join(path.dirname(sessionsRoot), "workspaces"); + if (workspaceScaffoldEnabled) { + try { + Deno.mkdirSync(dir, { recursive: true }); + } catch (err) { + logger.warn( + `[sim] unable to ensure workspace directory ${dir}: ${ + err instanceof Error ? err.message : err + }`, + ); + } } return dir; })(); + const workspaceById = new Map< + string, + { id: string; rootDir: string; rootDeckPath: string; createdAt: string } + >(); const ensureDir = (dir: string) => { try { Deno.mkdirSync(dir, { recursive: true }); @@ -835,19 +857,92 @@ export function startWebSocketSimulator(opts: { }; const buildBotRuns = new Map(); - const resolveBuildBotRoot = async (): Promise => { - if (buildBotRootCache) return buildBotRootCache; + const registerWorkspace = (record: { + id: string; + rootDir: string; + rootDeckPath: string; + createdAt: string; + }) => { + workspaceById.set(record.id, record); + return record; + }; + + const resolveWorkspaceRecord = ( + workspaceId?: string | null, + ): + | { id: string; rootDir: string; rootDeckPath: string; createdAt: string } + | null => { + if (!workspaceId) return null; + const cached = workspaceById.get(workspaceId); + if (cached) return cached; + const state = readSessionState(workspaceId); + const meta = state?.meta ?? {}; + const deckPath = typeof (meta as { workspaceRootDeckPath?: unknown }) + .workspaceRootDeckPath === "string" + ? (meta as { workspaceRootDeckPath: string }).workspaceRootDeckPath + : typeof meta.deck === "string" + ? meta.deck + : undefined; + const rootDir = + typeof (meta as { workspaceRootDir?: unknown }).workspaceRootDir === + "string" + ? (meta as { workspaceRootDir: string }).workspaceRootDir + : deckPath + ? path.dirname(deckPath) + : undefined; + if (!deckPath || !rootDir) return null; + const createdAt = + typeof (meta as { workspaceCreatedAt?: unknown }).workspaceCreatedAt === + "string" + ? (meta as { workspaceCreatedAt: string }).workspaceCreatedAt + : typeof meta.sessionCreatedAt === "string" + ? meta.sessionCreatedAt + : new Date().toISOString(); + return registerWorkspace({ + id: workspaceId, + rootDir, + rootDeckPath: deckPath, + createdAt, + }); + }; + + const resolveBuildBotRoot = async ( + workspaceId?: string | null, + ): Promise => { const override = Deno.env.get("GAMBIT_SIMULATOR_BUILD_BOT_ROOT")?.trim(); - const candidate = override || path.dirname(resolvedDeckPath); + if (override) { + const root = await Deno.realPath(override); + const info = await Deno.stat(root); + if (!info.isDirectory) { + throw new Error(`Build bot root is not a directory: ${root}`); + } + return root; + } + const cacheKey = workspaceId ?? "default"; + const cached = buildBotRootCache.get(cacheKey); + if (cached) return cached; + const record = resolveWorkspaceRecord(workspaceId); + const candidate = record?.rootDir ?? path.dirname(resolvedDeckPath); const root = await Deno.realPath(candidate); const info = await Deno.stat(root); if (!info.isDirectory) { throw new Error(`Build bot root is not a directory: ${root}`); } - buildBotRootCache = root; + buildBotRootCache.set(cacheKey, root); return root; }; + if ( + opts.workspace?.id && opts.workspace.rootDir && opts.workspace.rootDeckPath + ) { + registerWorkspace({ + id: opts.workspace.id, + rootDir: opts.workspace.rootDir, + rootDeckPath: opts.workspace.rootDeckPath, + createdAt: new Date().toISOString(), + }); + } + const MAX_FILE_PREVIEW_BYTES = 250_000; type BuildBotFileEntry = { @@ -938,6 +1033,7 @@ export function startWebSocketSimulator(opts: { } => { const meta = { ...(state.meta ?? {}) }; const now = new Date(); + meta.sessionUpdatedAt = now.toISOString(); if (typeof meta.sessionId !== "string") { const stamp = now.toISOString().replace(/[:.]/g, "-"); meta.sessionId = `${deckSlug}-${stamp}`; @@ -1287,6 +1383,115 @@ export function startWebSocketSimulator(opts: { } return undefined; }; + + const buildWorkspaceMeta = ( + record: { id: string; rootDir: string; rootDeckPath: string }, + base?: Record, + ): Record => { + const createdAt = + typeof (base as { sessionCreatedAt?: unknown })?.sessionCreatedAt === + "string" + ? (base as { sessionCreatedAt: string }).sessionCreatedAt + : typeof (base as { workspaceCreatedAt?: unknown }) + ?.workspaceCreatedAt === "string" + ? (base as { workspaceCreatedAt: string }).workspaceCreatedAt + : new Date().toISOString(); + return { + ...(base ?? {}), + workspaceId: record.id, + workspaceRootDeckPath: record.rootDeckPath, + workspaceRootDir: record.rootDir, + workspaceCreatedAt: (base as { workspaceCreatedAt?: string } | undefined) + ?.workspaceCreatedAt ?? createdAt, + sessionCreatedAt: (base as { sessionCreatedAt?: string } | undefined) + ?.sessionCreatedAt ?? createdAt, + deck: record.rootDeckPath, + deckSlug: deckSlugFromPath(record.rootDeckPath), + sessionId: record.id, + }; + }; + + const createWorkspaceSession = async ( + opts?: { onboarding?: boolean }, + ): Promise<{ + id: string; + rootDir: string; + rootDeckPath: string; + createdAt: string; + }> => { + const createdAt = new Date().toISOString(); + if (workspaceScaffoldEnabled) { + const scaffold = await createWorkspaceScaffold({ + baseDir: workspaceRoot, + }); + const record = registerWorkspace(scaffold); + persistSessionState({ + runId: record.id, + messages: [], + meta: buildWorkspaceMeta(record, { + sessionCreatedAt: record.createdAt, + workspaceCreatedAt: record.createdAt, + workspaceOnboarding: opts?.onboarding ?? false, + }), + }); + return record; + } + const workspaceId = randomId("workspace"); + const rootDeckPath = resolvedDeckPath; + const rootDir = path.dirname(rootDeckPath); + const record = registerWorkspace({ + id: workspaceId, + rootDir, + rootDeckPath, + createdAt, + }); + persistSessionState({ + runId: record.id, + messages: [], + meta: buildWorkspaceMeta(record, { + sessionCreatedAt: createdAt, + workspaceCreatedAt: createdAt, + workspaceOnboarding: opts?.onboarding ?? false, + }), + }); + return record; + }; + + if ( + opts.workspace?.id && opts.workspace.rootDir && opts.workspace.rootDeckPath + ) { + const existing = readSessionState(opts.workspace.id); + if (!existing) { + persistSessionState({ + runId: opts.workspace.id, + messages: [], + meta: buildWorkspaceMeta( + { + id: opts.workspace.id, + rootDir: opts.workspace.rootDir, + rootDeckPath: opts.workspace.rootDeckPath, + }, + { + sessionCreatedAt: new Date().toISOString(), + workspaceCreatedAt: new Date().toISOString(), + workspaceOnboarding: activeWorkspaceOnboarding, + }, + ), + }); + } + } + + const activateWorkspaceDeck = async (workspaceId?: string | null) => { + if (!workspaceId) return; + const record = resolveWorkspaceRecord(workspaceId); + if (!record) return; + const nextPath = resolveDeckPath(record.rootDeckPath); + if (nextPath === resolvedDeckPath) return; + resolvedDeckPath = nextPath; + buildBotRootCache.delete("default"); + reloadPrimaryDeck(); + await deckLoadPromise.catch(() => null); + }; const deleteSessionState = (sessionId: string): boolean => { if ( !sessionId || @@ -1369,71 +1574,37 @@ export function startWebSocketSimulator(opts: { return true; }; - const buildRunPath = (runId: string): string => - path.join(buildRunsRoot, runId, "run.json"); - const buildRunStatePath = (runId: string): string => - path.join(buildRunsRoot, runId, "state.json"); - - const persistBuildRun = (run: BuildBotRunStatus) => { - if (!isSafeRunId(run.id)) return; - const filePath = buildRunPath(run.id); - writeJsonAtomic(filePath, { - ...run, - updatedAt: new Date().toISOString(), - }); - }; - - const persistBuildRunState = (runId: string, state: SavedState) => { - if (!isSafeRunId(runId)) return; - const filePath = buildRunStatePath(runId); - const snapshot = materializeSnapshot(state); - writeJsonAtomic(filePath, snapshot); - }; - - const readBuildRun = (runId: string): BuildBotRunStatus | undefined => { - if (!isSafeRunId(runId)) return undefined; - const filePath = buildRunPath(runId); - try { - const text = Deno.readTextFileSync(filePath); - const parsed = JSON.parse(text) as BuildBotRunStatus; - if (parsed && typeof parsed === "object" && parsed.id === runId) { - return parsed; - } - } catch { - // ignore - } - return undefined; - }; - - const readBuildRunState = (runId: string): SavedState | undefined => { - if (!isSafeRunId(runId)) return undefined; - const filePath = buildRunStatePath(runId); - try { - const text = Deno.readTextFileSync(filePath); - const parsed = JSON.parse(text) as SavedState; - if (parsed && typeof parsed === "object") { - return parsed; - } - } catch { - // ignore - } - return undefined; - }; - const listBuildRuns = (): Array => { try { const entries: Array = []; - for (const entry of Deno.readDirSync(buildRunsRoot)) { - if (!entry.isDirectory) continue; - const run = readBuildRun(entry.name); - if (!run) continue; + for (const session of listSessions()) { + const state = readSessionState(session.id); + const meta = state?.meta ?? {}; + const buildStatus = + typeof (meta as { buildStatus?: unknown }).buildStatus === "string" + ? (meta as { buildStatus: BuildBotRunStatus["status"] }) + .buildStatus + : "idle"; + const buildChat = extractBuildChatState(state); entries.push({ - id: run.id, - status: run.status, - startedAt: run.startedAt, - finishedAt: run.finishedAt, - updatedAt: (run as { updatedAt?: string }).updatedAt, - messageCount: run.messages?.length ?? 0, + id: session.id, + status: buildStatus, + startedAt: + typeof (meta as { buildStartedAt?: unknown }).buildStartedAt === + "string" + ? (meta as { buildStartedAt: string }).buildStartedAt + : session.createdAt, + finishedAt: + typeof (meta as { buildFinishedAt?: unknown }).buildFinishedAt === + "string" + ? (meta as { buildFinishedAt: string }).buildFinishedAt + : undefined, + updatedAt: + typeof (meta as { sessionUpdatedAt?: unknown }).sessionUpdatedAt === + "string" + ? (meta as { sessionUpdatedAt: string }).sessionUpdatedAt + : session.createdAt, + messageCount: buildChat?.messages?.length ?? 0, }); } entries.sort((a, b) => { @@ -1798,6 +1969,54 @@ export function startWebSocketSimulator(opts: { run.traces = Array.isArray(state.traces) ? [...state.traces] : undefined; }; + const extractBuildChatState = ( + state?: SavedState, + ): SavedState | null => { + const meta = state?.meta; + if (!meta || typeof meta !== "object") return null; + const candidate = (meta as { buildChat?: unknown }).buildChat; + if (!candidate || typeof candidate !== "object") return null; + return candidate as SavedState; + }; + + const buildRunFromWorkspace = ( + workspaceId: string, + state?: SavedState, + ): BuildBotRunStatus => { + const meta = state?.meta; + const buildChatState = extractBuildChatState(state) ?? undefined; + const status = typeof (meta as { buildStatus?: unknown })?.buildStatus === + "string" + ? (meta as { buildStatus: BuildBotRunStatus["status"] }).buildStatus + : buildChatState + ? "completed" + : "idle"; + const run: BuildBotRunStatus = { + id: workspaceId, + status, + error: typeof (meta as { buildError?: unknown })?.buildError === "string" + ? (meta as { buildError: string }).buildError + : undefined, + startedAt: + typeof (meta as { buildStartedAt?: unknown })?.buildStartedAt === + "string" + ? (meta as { buildStartedAt: string }).buildStartedAt + : undefined, + finishedAt: + typeof (meta as { buildFinishedAt?: unknown })?.buildFinishedAt === + "string" + ? (meta as { buildFinishedAt: string }).buildFinishedAt + : undefined, + messages: [], + traces: [], + toolInserts: [], + }; + if (buildChatState) { + syncBuildBotRunFromState(run, buildChatState); + } + return run; + }; + const startTestBotRun = (runOpts: { maxTurnsOverride?: number; deckInput?: unknown; @@ -1809,6 +2028,9 @@ export function startWebSocketSimulator(opts: { args: Record; result: Record; }; + sessionId?: string; + workspaceRecord?: { id: string; rootDir: string; rootDeckPath: string }; + baseMeta?: Record; } = {}): TestBotRunStatus => { const botDeckPath = typeof runOpts.botDeckPath === "string" ? runOpts.botDeckPath @@ -1855,6 +2077,10 @@ export function startWebSocketSimulator(opts: { const run = entry.run; if (runOpts.initFill) run.initFill = runOpts.initFill; let savedState: SavedState | undefined = undefined; + const baseMeta = runOpts.baseMeta ?? {}; + const workspaceMeta = runOpts.workspaceRecord + ? buildWorkspaceMeta(runOpts.workspaceRecord, baseMeta) + : baseMeta; let lastCount = 0; const capturedTraces: Array = []; if (runOpts.initFillTrace) { @@ -1998,13 +2224,14 @@ export function startWebSocketSimulator(opts: { responsesMode: opts.responsesMode, onStateUpdate: (state) => { const nextMeta = { - ...(savedState?.meta ?? {}), + ...workspaceMeta, ...(state.meta ?? {}), testBot: true, testBotRunId: runId, testBotConfigPath: botConfigPath, testBotName, ...(run.initFill ? { testBotInitFill: run.initFill } : {}), + ...(runOpts.sessionId ? { sessionId: runOpts.sessionId } : {}), }; const enriched = persistSessionState({ ...state, @@ -2060,13 +2287,14 @@ export function startWebSocketSimulator(opts: { responsesMode: opts.responsesMode, onStateUpdate: (state) => { const nextMeta = { - ...(savedState?.meta ?? {}), + ...workspaceMeta, ...(state.meta ?? {}), testBot: true, testBotRunId: runId, testBotConfigPath: botConfigPath, testBotName, ...(run.initFill ? { testBotInitFill: run.initFill } : {}), + ...(runOpts.sessionId ? { sessionId: runOpts.sessionId } : {}), }; const enriched = persistSessionState({ ...state, @@ -2204,7 +2432,7 @@ export function startWebSocketSimulator(opts: { }) .then((deck) => { resolvedDeckPath = deck.path; - buildBotRootCache = null; + buildBotRootCache.clear(); deckSlug = deckSlugFromPath(resolvedDeckPath); rootStartMode = deck.startMode === "assistant" || deck.startMode === "user" @@ -2374,6 +2602,10 @@ export function startWebSocketSimulator(opts: { if (req.method !== "GET") { return new Response("Method not allowed", { status: 405 }); } + const sessionId = url.searchParams.get("sessionId") ?? undefined; + if (sessionId) { + await activateWorkspaceDeck(sessionId); + } await deckLoadPromise.catch(() => null); const sessions = listSessions(); return new Response( @@ -2398,6 +2630,7 @@ export function startWebSocketSimulator(opts: { throw new Error("Missing sessionId"); } const sessionId = body.sessionId; + await activateWorkspaceDeck(sessionId); await deckLoadPromise.catch(() => null); const grader = body.graderId ? resolveGraderDeck(body.graderId) @@ -2888,6 +3121,8 @@ export function startWebSocketSimulator(opts: { if (url.pathname === "/api/test") { if (req.method === "GET") { + const sessionId = url.searchParams.get("sessionId") ?? undefined; + await activateWorkspaceDeck(sessionId); await deckLoadPromise.catch(() => null); const requestedDeck = url.searchParams.get("deckPath"); const selection = requestedDeck @@ -2951,6 +3186,7 @@ export function startWebSocketSimulator(opts: { let inheritBotInput = false; let userProvidedDeckInput = false; let initFillRequestMissing: Array | undefined = undefined; + let sessionId: string | undefined = undefined; try { const body = await req.json() as { maxTurns?: number; @@ -2961,6 +3197,7 @@ export function startWebSocketSimulator(opts: { botDeckPath?: string; inheritBotInput?: unknown; initFill?: { missing?: unknown }; + sessionId?: string; }; if ( typeof body.maxTurns === "number" && Number.isFinite(body.maxTurns) @@ -2985,6 +3222,9 @@ export function startWebSocketSimulator(opts: { typeof entry === "string" && entry.trim().length > 0 ) as Array; } + if (typeof body.sessionId === "string") { + sessionId = body.sessionId; + } if (typeof body.botDeckPath === "string") { const resolved = resolveTestDeck(body.botDeckPath); if (!resolved) { @@ -3009,6 +3249,9 @@ export function startWebSocketSimulator(opts: { } catch { // ignore parse errors; use defaults } + if (sessionId) { + await activateWorkspaceDeck(sessionId); + } if (deckInput === undefined) { try { const desc = await schemaPromise; @@ -3199,6 +3442,20 @@ export function startWebSocketSimulator(opts: { { status: 400, headers: { "content-type": "application/json" } }, ); } + const existingSessionState = sessionId + ? readSessionState(sessionId) + : undefined; + const workspaceRecord = sessionId + ? resolveWorkspaceRecord(sessionId) ?? { + id: sessionId, + rootDir: path.dirname(resolvedDeckPath), + rootDeckPath: resolvedDeckPath, + createdAt: new Date().toISOString(), + } + : undefined; + if (workspaceRecord && !resolveWorkspaceRecord(sessionId)) { + registerWorkspace(workspaceRecord); + } const run = startTestBotRun({ maxTurnsOverride, deckInput, @@ -3207,6 +3464,10 @@ export function startWebSocketSimulator(opts: { botDeckPath: botDeckSelection.path, initFill: initFillInfo, initFillTrace, + sessionId, + workspaceRecord, + baseMeta: existingSessionState?.meta as Record ?? + undefined, }); return new Response( JSON.stringify({ run }), @@ -3241,6 +3502,9 @@ export function startWebSocketSimulator(opts: { const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : undefined; + if (sessionId) { + await activateWorkspaceDeck(sessionId); + } let savedState = sessionId ? readSessionState(sessionId, { withTraces: true }) : undefined; @@ -3258,6 +3522,23 @@ export function startWebSocketSimulator(opts: { : savedState.runId; } runId = runId ?? randomId("testbot"); + const workspaceRecord = sessionId + ? resolveWorkspaceRecord(sessionId) ?? { + id: sessionId, + rootDir: path.dirname(resolvedDeckPath), + rootDeckPath: resolvedDeckPath, + createdAt: new Date().toISOString(), + } + : undefined; + if (workspaceRecord && !resolveWorkspaceRecord(sessionId)) { + registerWorkspace(workspaceRecord); + } + const workspaceMeta = workspaceRecord + ? buildWorkspaceMeta( + workspaceRecord, + savedState?.meta as Record ?? {}, + ) + : (savedState?.meta ?? {}); const existingEntry = testBotRuns.get(runId); if (existingEntry?.promise) { return new Response( @@ -3429,12 +3710,13 @@ export function startWebSocketSimulator(opts: { onStateUpdate: (state) => { if (isAborted()) return; const nextMeta = { - ...(savedState?.meta ?? {}), + ...workspaceMeta, ...(state.meta ?? {}), testBot: true, testBotRunId: runId, testBotConfigPath: botConfigPath, testBotName, + ...(sessionId ? { sessionId } : {}), }; const enriched = persistSessionState({ ...state, @@ -3517,6 +3799,9 @@ export function startWebSocketSimulator(opts: { if (url.pathname === "/api/test/status") { const runId = url.searchParams.get("runId") ?? undefined; const sessionId = url.searchParams.get("sessionId") ?? undefined; + if (sessionId) { + await activateWorkspaceDeck(sessionId); + } let entry = runId ? testBotRuns.get(runId) : undefined; if (!entry && sessionId) { for (const candidate of testBotRuns.values()) { @@ -3635,16 +3920,23 @@ export function startWebSocketSimulator(opts: { if (req.method !== "GET") { return new Response("Method not allowed", { status: 405 }); } - const runId = url.searchParams.get("runId") ?? undefined; - const entry = runId ? buildBotRuns.get(runId) : undefined; - const storedRun = runId && !entry ? readBuildRun(runId) : undefined; - const run = entry?.run ?? storedRun ?? { - id: runId ?? "", - status: "idle", - messages: [], - traces: [], - toolInserts: [], - }; + const workspaceId = url.searchParams.get("workspaceId") ?? + url.searchParams.get("runId") ?? + activeWorkspaceId ?? + undefined; + const entry = workspaceId ? buildBotRuns.get(workspaceId) : undefined; + const workspaceState = workspaceId + ? readSessionState(workspaceId, { withTraces: true }) + : undefined; + const run = workspaceId + ? entry?.run ?? buildRunFromWorkspace(workspaceId, workspaceState) + : { + id: "", + status: "idle", + messages: [], + traces: [], + toolInserts: [], + }; return new Response(JSON.stringify({ run }), { headers: { "content-type": "application/json" }, }); @@ -3654,20 +3946,28 @@ export function startWebSocketSimulator(opts: { if (req.method !== "POST") { return new Response("Method not allowed", { status: 405 }); } - let runId: string | undefined = undefined; + let workspaceId: string | undefined = undefined; try { - const body = await req.json() as { runId?: string }; - if (typeof body.runId === "string") runId = body.runId; + const body = await req.json() as { + runId?: string; + workspaceId?: string; + }; + if (typeof body.workspaceId === "string") { + workspaceId = body.workspaceId; + } + if (typeof body.runId === "string" && !workspaceId) { + workspaceId = body.runId; + } } catch { // ignore } - if (!runId) { + if (!workspaceId) { return new Response( - JSON.stringify({ error: "Missing runId" }), + JSON.stringify({ error: "Missing workspaceId" }), { status: 400, headers: { "content-type": "application/json" } }, ); } - const entry = buildBotRuns.get(runId); + const entry = buildBotRuns.get(workspaceId); if (entry?.abort) { entry.abort.abort(); } @@ -3677,13 +3977,24 @@ export function startWebSocketSimulator(opts: { } entry.run.finishedAt = entry.run.finishedAt ?? new Date().toISOString(); - persistBuildRun(entry.run); + const state = readSessionState(workspaceId); + if (state) { + persistSessionState({ + ...state, + meta: { + ...(state.meta ?? {}), + buildStatus: entry.run.status, + buildFinishedAt: entry.run.finishedAt, + buildError: entry.run.error, + }, + }); + } } - buildBotRuns.delete(runId); + buildBotRuns.delete(workspaceId); broadcastBuildBot({ type: "buildBotStatus", run: { - id: runId, + id: workspaceId, status: "idle", messages: [], traces: [], @@ -3701,6 +4012,7 @@ export function startWebSocketSimulator(opts: { } let payload: { runId?: unknown; + workspaceId?: unknown; message?: unknown; model?: unknown; modelForce?: unknown; @@ -3710,18 +4022,30 @@ export function startWebSocketSimulator(opts: { } catch { // ignore } - const runId = typeof payload.runId === "string" ? payload.runId : ""; - if (!runId) { - return new Response( - JSON.stringify({ error: "Missing runId" }), - { status: 400, headers: { "content-type": "application/json" } }, - ); + let workspaceId = typeof payload.workspaceId === "string" + ? payload.workspaceId + : typeof payload.runId === "string" + ? payload.runId + : activeWorkspaceId ?? undefined; + if (!workspaceId) { + const created = await createWorkspaceSession(); + workspaceId = created.id; } const message = typeof payload.message === "string" ? payload.message : ""; - const existingEntry = buildBotRuns.get(runId); + const workspaceRecord = resolveWorkspaceRecord(workspaceId) ?? { + id: workspaceId, + rootDir: path.dirname(resolvedDeckPath), + rootDeckPath: resolvedDeckPath, + createdAt: new Date().toISOString(), + }; + if (!resolveWorkspaceRecord(workspaceId)) { + registerWorkspace(workspaceRecord); + } + + const existingEntry = buildBotRuns.get(workspaceId); if (existingEntry?.promise) { return new Response( JSON.stringify({ error: "Run already in progress" }), @@ -3731,7 +4055,7 @@ export function startWebSocketSimulator(opts: { const entry = existingEntry ?? { run: { - id: runId, + id: workspaceId, status: "idle", messages: [], traces: [], @@ -3741,15 +4065,16 @@ export function startWebSocketSimulator(opts: { promise: null, abort: null, }; - buildBotRuns.set(runId, entry); + buildBotRuns.set(workspaceId, entry); - if (!entry.state && (entry.run.messages?.length ?? 0) > 0) { - return new Response( - JSON.stringify({ - error: "Run history loaded without resumable state", - }), - { status: 409, headers: { "content-type": "application/json" } }, - ); + if (!entry.state) { + const workspaceState = readSessionState(workspaceId, { + withTraces: true, + }); + const buildChat = extractBuildChatState(workspaceState); + if (buildChat) { + entry.state = buildChat; + } } const run = entry.run; @@ -3760,21 +4085,47 @@ export function startWebSocketSimulator(opts: { syncBuildBotRunFromState(run, entry.state); } broadcastBuildBot({ type: "buildBotStatus", run }); - persistBuildRun(run); + const workspaceBaseState = readSessionState(workspaceId) ?? { + runId: workspaceId, + messages: [], + meta: {}, + }; + persistSessionState({ + ...workspaceBaseState, + meta: { + ...buildWorkspaceMeta( + workspaceRecord, + workspaceBaseState.meta ?? {}, + ), + buildStatus: run.status, + buildStartedAt: run.startedAt, + }, + }); const controller = new AbortController(); entry.abort = controller; const isAborted = () => controller.signal.aborted; const botDeckUrl = new URL( - "./decks/gambit-bot.deck.md", + "./decks/gambit-bot/PROMPT.md", import.meta.url, ); if (botDeckUrl.protocol !== "file:") { run.status = "error"; run.error = "Unable to resolve Gambit Bot deck path"; broadcastBuildBot({ type: "buildBotStatus", run }); - persistBuildRun(run); + const state = readSessionState(workspaceId); + if (state) { + persistSessionState({ + ...state, + meta: { + ...(state.meta ?? {}), + buildStatus: "error", + buildError: run.error, + buildFinishedAt: new Date().toISOString(), + }, + }); + } return new Response( JSON.stringify({ error: run.error }), { status: 500, headers: { "content-type": "application/json" } }, @@ -3784,13 +4135,24 @@ export function startWebSocketSimulator(opts: { let botRoot: string; try { - botRoot = await resolveBuildBotRoot(); + botRoot = await resolveBuildBotRoot(workspaceId); } catch (err) { const msg = err instanceof Error ? err.message : String(err); run.status = "error"; run.error = msg; broadcastBuildBot({ type: "buildBotStatus", run }); - persistBuildRun(run); + const state = readSessionState(workspaceId); + if (state) { + persistSessionState({ + ...state, + meta: { + ...(state.meta ?? {}), + buildStatus: "error", + buildError: msg, + buildFinishedAt: new Date().toISOString(), + }, + }); + } return new Response( JSON.stringify({ error: msg }), { status: 400, headers: { "content-type": "application/json" } }, @@ -3813,8 +4175,22 @@ export function startWebSocketSimulator(opts: { syncBuildBotRunFromState(run, state); run.traces = Array.isArray(state.traces) ? [...state.traces] : []; broadcastBuildBot({ type: "buildBotStatus", run }); - persistBuildRun(run); - persistBuildRunState(runId, state); + const base = readSessionState(workspaceId) ?? { + runId: workspaceId, + messages: [], + meta: {}, + }; + persistSessionState({ + ...base, + meta: { + ...buildWorkspaceMeta(workspaceRecord, base.meta ?? {}), + buildStatus: run.status, + buildStartedAt: run.startedAt, + buildFinishedAt: run.finishedAt, + buildError: run.error, + buildChat: state, + }, + }); }; entry.promise = (async () => { @@ -3854,7 +4230,7 @@ export function startWebSocketSimulator(opts: { onStreamText: (chunk) => broadcastBuildBot({ type: "buildBotStream", - runId, + runId: workspaceId, role: "assistant", chunk, turn, @@ -3864,7 +4240,7 @@ export function startWebSocketSimulator(opts: { if (shouldStream) { broadcastBuildBot({ type: "buildBotStreamEnd", - runId, + runId: workspaceId, role: "assistant", turn, ts: Date.now(), @@ -3898,6 +4274,23 @@ export function startWebSocketSimulator(opts: { run.finishedAt = new Date().toISOString(); entry.abort = null; entry.promise = null; + const base = readSessionState(workspaceId) ?? { + runId: workspaceId, + messages: [], + meta: {}, + }; + const buildChatState = entry.state ?? extractBuildChatState(base); + persistSessionState({ + ...base, + meta: { + ...buildWorkspaceMeta(workspaceRecord, base.meta ?? {}), + buildStatus: run.status, + buildStartedAt: run.startedAt, + buildFinishedAt: run.finishedAt, + buildError: run.error, + buildChat: buildChatState ?? undefined, + }, + }); try { reloadPrimaryDeck(); } catch (err) { @@ -3908,7 +4301,6 @@ export function startWebSocketSimulator(opts: { ); } broadcastBuildBot({ type: "buildBotStatus", run }); - persistBuildRun(run); if (prevBotRoot === undefined) { try { Deno.env.delete("GAMBIT_BOT_ROOT"); @@ -3931,7 +4323,10 @@ export function startWebSocketSimulator(opts: { return new Response("Method not allowed", { status: 405 }); } try { - const root = await resolveBuildBotRoot(); + const workspaceId = url.searchParams.get("workspaceId") ?? + activeWorkspaceId ?? + undefined; + const root = await resolveBuildBotRoot(workspaceId); const entries = await listBuildBotFiles(root); return new Response(JSON.stringify({ root, entries }), { headers: { "content-type": "application/json" }, @@ -3957,7 +4352,10 @@ export function startWebSocketSimulator(opts: { }); } try { - const root = await resolveBuildBotRoot(); + const workspaceId = url.searchParams.get("workspaceId") ?? + activeWorkspaceId ?? + undefined; + const root = await resolveBuildBotRoot(workspaceId); const resolved = await resolveBuildBotPath(root, inputPath); if (!resolved.stat.isFile) { return new Response( @@ -4747,7 +5145,10 @@ export function startWebSocketSimulator(opts: { await deckLoadPromise.catch(() => null); const resolvedLabel = deckLabel ?? toDeckLabel(resolvedDeckPath); return new Response( - simulatorReactHtml(resolvedDeckPath, resolvedLabel), + simulatorReactHtml(resolvedDeckPath, resolvedLabel, { + workspaceId: activeWorkspaceId ?? null, + onboarding: activeWorkspaceOnboarding, + }), { headers: { "content-type": "text/html; charset=utf-8" }, }, @@ -4755,6 +5156,10 @@ export function startWebSocketSimulator(opts: { } if (url.pathname === "/schema") { + const sessionId = url.searchParams.get("sessionId") ?? undefined; + if (sessionId) { + await activateWorkspaceDeck(sessionId); + } const desc = await schemaPromise; const deck = await deckLoadPromise.catch(() => null); const modelParams = deck && typeof deck === "object" @@ -4863,6 +5268,31 @@ export function startWebSocketSimulator(opts: { headers: { "content-type": "application/json; charset=utf-8" }, }); } + if (url.pathname === "/api/workspace/new") { + if (req.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + try { + const workspace = await createWorkspaceSession(); + await activateWorkspaceDeck(workspace.id); + return new Response( + JSON.stringify({ + workspaceId: workspace.id, + deckPath: workspace.rootDeckPath, + workspaceDir: workspace.rootDir, + createdAt: workspace.createdAt, + }), + { headers: { "content-type": "application/json" } }, + ); + } catch (err) { + return new Response( + JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + }), + { status: 500, headers: { "content-type": "application/json" } }, + ); + } + } if (url.pathname === "/api/build/runs") { const runs = listBuildRuns(); return new Response(JSON.stringify({ runs }), { @@ -4874,35 +5304,32 @@ export function startWebSocketSimulator(opts: { return new Response("Method not allowed", { status: 405 }); } try { - const body = await req.json() as { runId?: string }; - if (!body.runId || !isSafeRunId(body.runId)) { - throw new Error("Missing runId"); + const body = await req.json() as { + runId?: string; + workspaceId?: string; + }; + const workspaceId = body.workspaceId ?? body.runId ?? ""; + if (!workspaceId || !isSafeRunId(workspaceId)) { + throw new Error("Missing workspaceId"); } - const saved = readBuildRun(body.runId); - if (!saved) { + const state = readSessionState(workspaceId, { withTraces: true }); + if (!state) { return new Response( - JSON.stringify({ error: "Run not found" }), + JSON.stringify({ error: "Workspace not found" }), { status: 404, headers: { "content-type": "application/json" } }, ); } - const savedState = readBuildRunState(body.runId); - const hydratedState = savedState - ? { - ...savedState, - traces: Array.isArray(saved.traces) - ? [...saved.traces] - : savedState.traces, - } - : null; + const buildChat = extractBuildChatState(state); + const run = buildRunFromWorkspace(workspaceId, state); const entry: BuildBotRunEntry = { - run: saved, - state: hydratedState, + run, + state: buildChat, promise: null, abort: null, }; - buildBotRuns.set(body.runId, entry); - broadcastBuildBot({ type: "buildBotStatus", run: saved }); - return new Response(JSON.stringify({ run: saved }), { + buildBotRuns.set(workspaceId, entry); + broadcastBuildBot({ type: "buildBotStatus", run }); + return new Response(JSON.stringify({ run }), { headers: { "content-type": "application/json" }, }); } catch (err) { @@ -5045,16 +5472,21 @@ async function readRemoteBundle( } } -function simulatorReactHtml(deckPath: string, deckLabel?: string): string { +function simulatorReactHtml( + deckPath: string, + deckLabel?: string, + opts?: { workspaceId?: string | null; onboarding?: boolean }, +): string { const safeDeckPath = deckPath.replaceAll("<", "<").replaceAll(">", ">"); const safeDeckLabel = deckLabel?.replaceAll("<", "<").replaceAll(">", ">") ?? null; const buildTabEnabled = (() => { - const raw = Deno.env.get("GAMBIT_SIMULATOR_BUILD_TAB") ?? ""; + const raw = Deno.env.get("GAMBIT_SIMULATOR_BUILD_TAB"); + if (raw === undefined) return true; const normalized = raw.trim().toLowerCase(); - return normalized === "1" || normalized === "true" || - normalized === "yes" || - normalized === "on"; + return !(normalized === "0" || normalized === "false" || + normalized === "no" || + normalized === "off"); })(); const chatAccordionEnabled = (() => { const raw = Deno.env.get("GAMBIT_SIMULATOR_CHAT_ACCORDION"); @@ -5076,6 +5508,8 @@ function simulatorReactHtml(deckPath: string, deckLabel?: string): string { const bundleUrl = bundleStamp ? `/ui/bundle.js?v=${bundleStamp}` : "/ui/bundle.js"; + const workspaceId = opts?.workspaceId ?? null; + const workspaceOnboarding = Boolean(opts?.onboarding); return ` @@ -5098,6 +5532,12 @@ function simulatorReactHtml(deckPath: string, deckLabel?: string): string { JSON.stringify( chatAccordionEnabled, ) + }; + window.__GAMBIT_WORKSPACE_ID__ = ${JSON.stringify(workspaceId)}; + window.__GAMBIT_WORKSPACE_ONBOARDING__ = ${ + JSON.stringify( + workspaceOnboarding, + ) };