diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ac225d4..8cfbd91 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -35,6 +35,7 @@ import { createRunSecretWriter, createWebProvider, destroyBot, + destroySpaceSandboxes, EmailEmulator, EncryptedSecretStore, ExpoPushSender, @@ -83,6 +84,7 @@ import { parsePositiveInteger, provisionMessagingIdentity, requireMembership, + spaceIdsForSandboxTeardownAfterAccountDelete, } from "@rakazo/db"; import type { Logger } from "@rakazo/logging"; import { @@ -365,23 +367,38 @@ export async function createApp( where: { userId }, select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true }, }); + const lifecycleDeps = { + prisma, + sandbox, + home, + jobs, + artifacts, + dataDir: env.dataDir, + }; + const deleteContext = { + operationId: `account-delete:${userId}`, + traceId: `account-delete:${userId}`, + userId, + signal: new AbortController().signal, + }; await Promise.all( bots.map((bot) => destroyBot( - { prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir }, + lifecycleDeps, bot, - { - operationId: `account-delete:${userId}`, - traceId: `account-delete:${userId}`, - spaceId: bot.spaceId, - userId, - botId: bot.id, - signal: new AbortController().signal, - }, + { ...deleteContext, spaceId: bot.spaceId, botId: bot.id }, { deleteMemories: true }, ), ), ); + const candidateSpaceIds = [...new Set(bots.map((bot) => bot.spaceId))]; + const spaceIds = await spaceIdsForSandboxTeardownAfterAccountDelete(prisma, { + userId, + spaceIds: candidateSpaceIds, + }); + for (const spaceId of spaceIds) { + await destroySpaceSandboxes(lifecycleDeps, { spaceId }, { ...deleteContext, spaceId }); + } await removePiUserSessions(env.dataDir, userId); }, }); diff --git a/apps/web/e2e/chat-queued-progress.spec.ts b/apps/web/e2e/chat-queued-progress.spec.ts new file mode 100644 index 0000000..1dccdab --- /dev/null +++ b/apps/web/e2e/chat-queued-progress.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; +import { completeOnboarding, routePatchRpc, signup, waitForChatComposer } from "./helpers"; + +const viewports = [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 390, height: 844 }, +]; + +for (const viewport of viewports) { + test(`queued run shows waiting status (${viewport.name})`, async ({ page }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + const stamp = Date.now(); + await signup(page, `queued-${viewport.name}-${stamp}@rakazo.test`, "password12", "Queue"); + await completeOnboarding(page); + await waitForChatComposer(page); + const botId = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1)!; + let holdQueued = false; + + await routePatchRpc(page, "threads/get", (json) => { + if (!holdQueued) return json; + const run = { + id: "run-queued-e2e", + botId, + threadId: String(json.threadId ?? "thread-e2e"), + taskId: "task-queued-e2e", + status: "queued", + trigger: "user", + routineId: null, + modelProvider: null, + modelId: null, + error: null, + startedAt: null, + completedAt: null, + createdAt: new Date().toISOString(), + }; + return { ...json, run, activeRuns: [run] }; + }); + + await page.route("**/rpc/threads/send", async (route) => { + holdQueued = true; + await route.continue(); + }); + const composer = page.getByRole("combobox", { name: /Message|Nachricht/i }); + await composer.fill("Hallo"); + await composer.press("Enter"); + await expect( + page.getByRole("status").filter({ hasText: /Queued|Wartet|Working|Arbeitet/i }), + ).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByRole("button", { name: /Stop|Stopp/i })).toBeVisible(); + }); +} diff --git a/apps/web/e2e/journey-console-errors.spec.ts b/apps/web/e2e/journey-console-errors.spec.ts new file mode 100644 index 0000000..171140c --- /dev/null +++ b/apps/web/e2e/journey-console-errors.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from "@playwright/test"; +import { completeOnboarding, openUserSettings, signup, waitForChatComposer } from "./helpers"; + +const ALLOWED_CONSOLE = [ + /Failed to load resource.*404/i, + /favicon/i, + /ERR_ABORTED/i, + /Content Security Policy/i, + /net::ERR_/i, +]; + +function attachConsoleGuard(page: import("@playwright/test").Page) { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() !== "error") return; + const text = msg.text(); + if (ALLOWED_CONSOLE.some((pattern) => pattern.test(text))) return; + errors.push(text); + }); + page.on("pageerror", (err) => errors.push(err.message)); + return errors; +} + +test.describe("desktop journeys", () => { + test.use({ viewport: { width: 1440, height: 900 } }); + + test("sign-up through settings has no unexpected console errors", async ({ page }) => { + const errors = attachConsoleGuard(page); + const stamp = Date.now(); + await signup(page, `console-j-${stamp}@rakazo.test`, "password12", "Console QA"); + await completeOnboarding(page); + await waitForChatComposer(page); + await page.goto("/app/heute"); + await expect(page).toHaveURL(/\/app\/heute/); + await openUserSettings(page); + expect(errors, errors.join("\n")).toEqual([]); + }); +}); + +test.describe("mobile journeys", () => { + test.use({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + }); + + test("onboarding and chat shell stay console-clean", async ({ page }) => { + const errors = attachConsoleGuard(page); + const stamp = Date.now(); + await signup(page, `console-m-${stamp}@rakazo.test`, "password12", "Mobile Console"); + await completeOnboarding(page); + await waitForChatComposer(page); + expect(errors, errors.join("\n")).toEqual([]); + }); +}); diff --git a/apps/web/e2e/onboarding-intake-de.spec.ts b/apps/web/e2e/onboarding-intake-de.spec.ts new file mode 100644 index 0000000..4f2ad93 --- /dev/null +++ b/apps/web/e2e/onboarding-intake-de.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; +import { completeOnboarding, signup } from "./helpers"; + +test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + +test("German UI localizes goal placeholder and question in intake", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true }); + localStorage.setItem("rakazo.uiLocale", "de"); + }); + const stamp = Date.now(); + await signup(page, `intake-de-${stamp}@rakazo.test`, "password12", "Deutsch Intake"); + await completeOnboarding(page, undefined, { mainConversation: true }); + await page.getByTestId("focus-choice-day").click(); + + const goal = page.locator('[data-testid="choice-card"][data-step="goal"]'); + await expect(goal).toContainText("Was möchtest du in den nächsten Wochen erledigt bekommen?"); + await expect( + goal.getByRole("textbox", { + name: "Was möchtest du in den nächsten Wochen erledigt bekommen?", + }), + ).toHaveAttribute("placeholder", "Dein Ziel"); +}); diff --git a/apps/web/e2e/onboarding-reload-persistence.spec.ts b/apps/web/e2e/onboarding-reload-persistence.spec.ts new file mode 100644 index 0000000..f709bde --- /dev/null +++ b/apps/web/e2e/onboarding-reload-persistence.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from "@playwright/test"; +import { signup } from "./helpers"; + +test.describe("desktop", () => { + test.use({ viewport: { width: 1440, height: 900 } }); + + test("reload during onboarding restores Oo look and step", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `onboard-reload-${stamp}@rakazo.test`, "password12", "Reload"); + await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); + await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click(); + await expect(page.getByTestId("onboarding-name-step")).toBeVisible(); + await page.getByTestId("onboarding-oo-name-input").fill("Nova"); + await page.reload(); + await expect(page.getByTestId("onboarding-name-step")).toBeVisible(); + await expect(page.getByTestId("onboarding-oo-name-input")).toHaveValue("Nova"); + await expect(page.locator('[data-onboarding-step="name"]')).toBeVisible(); + }); +}); + +test.describe("mobile", () => { + test.use({ + hasTouch: true, + isMobile: true, + viewport: { width: 390, height: 844 }, + }); + + test("reload during look step keeps category progress", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `onboard-reload-m-${stamp}@rakazo.test`, "password12", "Mobile"); + await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); + await expect(page.locator('[data-onboarding-step="emoji"]')).toBeVisible(); + await page.reload(); + await expect(page.locator('[data-onboarding-step="emoji"]')).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Design your Oo|Gestalte dein Oo/i }), + ).toBeVisible(); + }); +}); diff --git a/apps/web/src/components/ai/CollaborationMarker.tsx b/apps/web/src/components/ai/CollaborationMarker.tsx index 4c81945..57f9930 100644 --- a/apps/web/src/components/ai/CollaborationMarker.tsx +++ b/apps/web/src/components/ai/CollaborationMarker.tsx @@ -1,5 +1,6 @@ import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web"; import { useEffect, useMemo, useState } from "react"; +import { isDoingStatus } from "../../lib/agent-activity"; import { personaWorkingLinesPlain, resolveBotPersona } from "../../lib/bot-persona"; import { Shimmer } from "./primitives"; @@ -35,8 +36,17 @@ export function CollaborationMarker({ ); } -export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; label: string }) { +export function ActiveBotGlyph({ + bots, + label, + runStatus, +}: { + bots: GroupAvatarMember[]; + label: string; + runStatus?: string; +}) { const firstBot = bots[0]; + const status = runStatus ?? firstBot?.status; const persona = useMemo( () => firstBot?.name @@ -50,8 +60,9 @@ export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; lab ); const workingLines = useMemo(() => { if (!firstBot?.name || !persona) return [label]; + if (isDoingStatus(status) && status !== "running") return [label]; return [...personaWorkingLinesPlain(persona.archetype, firstBot.name)]; - }, [firstBot?.name, label, persona]); + }, [firstBot?.name, label, persona, status]); const [phraseIndex, setPhraseIndex] = useState(0); useEffect(() => { diff --git a/apps/web/src/components/onboarding/OnboardingDiscoveryShell.tsx b/apps/web/src/components/onboarding/OnboardingDiscoveryShell.tsx index 2cf290c..61b9927 100644 --- a/apps/web/src/components/onboarding/OnboardingDiscoveryShell.tsx +++ b/apps/web/src/components/onboarding/OnboardingDiscoveryShell.tsx @@ -2,6 +2,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { EvooMascot } from "@rakazo/ui-web"; import type { ReactNode } from "react"; import { + DISCOVERY_MAIN_BOT_ROLE, type DiscoveryItem, type DiscoverySourceId, discoveryProgress, @@ -83,6 +84,14 @@ export function OnboardingDiscoveryShell({ {active ? ( <> {sourceLabel(active.id, t)} + {active.status === "found" && active.detail === DISCOVERY_MAIN_BOT_ROLE ? ( + <> + {" · "} + Chief of Staff + + ) : active.detail && active.detail !== DISCOVERY_MAIN_BOT_ROLE ? ( + <> · {active.detail} + ) : null} {active.status === "scanning" ? "…" : null} ) : done >= total && total > 0 ? ( diff --git a/apps/web/src/components/onboarding/OnboardingNameStep.tsx b/apps/web/src/components/onboarding/OnboardingNameStep.tsx index c7d9c1e..c41a767 100644 --- a/apps/web/src/components/onboarding/OnboardingNameStep.tsx +++ b/apps/web/src/components/onboarding/OnboardingNameStep.tsx @@ -1,7 +1,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { EvooPose } from "@rakazo/ui-web"; import { BotAvatar, Button } from "@rakazo/ui-web"; -import { useId, useState } from "react"; +import { useEffect, useId, useState } from "react"; import type { OnboardingEmojiConfig } from "./OnboardingOoDesigner"; export const DEFAULT_FIRST_OO_NAME = "Chief"; @@ -25,6 +25,10 @@ export function OnboardingNameStep({ const inputId = useId(); const [draft, setDraft] = useState(name); + useEffect(() => { + setDraft(name); + }, [name]); + function commitAndContinue() { const trimmed = draft.trim() || DEFAULT_FIRST_OO_NAME; onNameChange(trimmed); @@ -62,7 +66,11 @@ export function OnboardingNameStep({ autoCapitalize="words" placeholder={DEFAULT_FIRST_OO_NAME} value={draft} - onChange={(event) => setDraft(event.target.value)} + onChange={(event) => { + const value = event.target.value; + setDraft(value); + onNameChange(value); + }} onKeyDown={(event) => { if (event.key === "Enter") commitAndContinue(); }} diff --git a/apps/web/src/lib/onboarding-discovery.ts b/apps/web/src/lib/onboarding-discovery.ts index 2226ae9..53f8e2a 100644 --- a/apps/web/src/lib/onboarding-discovery.ts +++ b/apps/web/src/lib/onboarding-discovery.ts @@ -1,6 +1,9 @@ import type { IntegrationSetupState, Me } from "@rakazo/contracts"; import { collectLocalProfileSignals, type UserProfileSignals } from "./user-profile-harvest"; +/** Sentinel stored in discovery `detail`; the shell renders it with Lingui. */ +export const DISCOVERY_MAIN_BOT_ROLE = "main-bot-role"; + export type DiscoverySourceId = "profile-basics" | "connected-tools" | "specialist-crew"; export type DiscoveryStatus = "pending" | "scanning" | "found" | "skipped"; @@ -81,7 +84,7 @@ export function computeDiscoveryItems(context: DiscoveryContext): DiscoveryItem[ const crew: DiscoveryItem = (() => { if (context.crewReady) { - return { id: "specialist-crew", status: "found", detail: "Chief of Staff" }; + return { id: "specialist-crew", status: "found", detail: DISCOVERY_MAIN_BOT_ROLE }; } if (context.phase === "assembly") { return { id: "specialist-crew", status: "scanning" }; diff --git a/apps/web/src/lib/onboarding-draft.test.ts b/apps/web/src/lib/onboarding-draft.test.ts new file mode 100644 index 0000000..cdb0a2c --- /dev/null +++ b/apps/web/src/lib/onboarding-draft.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + clearOnboardingDraft, + ONBOARDING_DRAFT_STORAGE_KEY, + persistOnboardingDraft, + readOnboardingDraft, +} from "./onboarding-draft.js"; + +describe("onboarding draft persistence", () => { + const storage = new Map(); + const session = { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }; + + it("round-trips look, name and step through session storage", () => { + persistOnboardingDraft( + { + step: "name", + emojiConfig: { pose: "wave", avatarValue: "oo1:c=FF0000" }, + ooName: "Luna", + languageChoice: "de", + currentLocale: "de", + selectedVoiceId: "df_eva", + voiceSkipped: false, + }, + session, + ); + expect(readOnboardingDraft(session)).toEqual({ + step: "name", + emojiConfig: { pose: "wave", avatarValue: "oo1:c=FF0000" }, + ooName: "Luna", + languageChoice: "de", + currentLocale: "de", + selectedVoiceId: "df_eva", + voiceSkipped: false, + }); + clearOnboardingDraft(session); + expect(storage.has(ONBOARDING_DRAFT_STORAGE_KEY)).toBe(false); + }); +}); diff --git a/apps/web/src/lib/onboarding-draft.ts b/apps/web/src/lib/onboarding-draft.ts new file mode 100644 index 0000000..33e523f --- /dev/null +++ b/apps/web/src/lib/onboarding-draft.ts @@ -0,0 +1,93 @@ +import type { OnboardingEmojiConfig } from "../components/onboarding/OnboardingOoDesigner"; +import type { UiLocale } from "./ui-locale"; + +export const ONBOARDING_DRAFT_STORAGE_KEY = "rakazo.onboardingDraft"; + +export type OnboardingStepDraft = + | "emoji" + | "name" + | "language" + | "voice" + | "integrations" + | "scan" + | "bot"; + +export type OnboardingDraft = { + step: OnboardingStepDraft; + emojiConfig: OnboardingEmojiConfig; + ooName: string; + languageChoice: UiLocale | null; + currentLocale: UiLocale; + selectedVoiceId: string; + voiceSkipped: boolean; +}; + +function readStorage( + storage: Pick | null | undefined, +): Pick | null { + if (storage !== undefined) return storage; + return typeof sessionStorage !== "undefined" ? sessionStorage : null; +} + +function writeStorage( + storage: Pick | null | undefined, +): Pick | null { + if (storage !== undefined) return storage; + return typeof sessionStorage !== "undefined" ? sessionStorage : null; +} + +/** Restore in-progress onboarding after reload or deep link within the same tab. */ +export function readOnboardingDraft( + storage: Pick | null = readStorage(undefined), +): OnboardingDraft | null { + if (!storage) return null; + try { + const raw = storage.getItem(ONBOARDING_DRAFT_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if ( + !parsed || + typeof parsed.step !== "string" || + !parsed.emojiConfig || + typeof parsed.emojiConfig.avatarValue !== "string" || + typeof parsed.emojiConfig.pose !== "string" || + typeof parsed.ooName !== "string" + ) { + return null; + } + return { + step: parsed.step as OnboardingStepDraft, + emojiConfig: parsed.emojiConfig, + ooName: parsed.ooName, + languageChoice: parsed.languageChoice ?? null, + currentLocale: parsed.currentLocale ?? "en", + selectedVoiceId: parsed.selectedVoiceId ?? "", + voiceSkipped: parsed.voiceSkipped ?? false, + }; + } catch { + return null; + } +} + +export function persistOnboardingDraft( + draft: OnboardingDraft, + storage: Pick | null = writeStorage(undefined), +): void { + if (!storage) return; + try { + storage.setItem(ONBOARDING_DRAFT_STORAGE_KEY, JSON.stringify(draft)); + } catch { + // Quota / private mode — onboarding still works without persistence. + } +} + +export function clearOnboardingDraft( + storage: Pick | null = writeStorage(undefined), +): void { + if (!storage) return; + try { + storage.removeItem(ONBOARDING_DRAFT_STORAGE_KEY); + } catch { + // Ignore storage failures on completion. + } +} diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index f65f2db..e5ea528 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -1095,6 +1095,10 @@ msgstr "Wangen" msgid "Chef's hat" msgstr "Kochmütze" +#: src/components/onboarding/OnboardingDiscoveryShell.tsx +msgid "Chief of Staff" +msgstr "Stabschef" + #: src/pages/MessagingSettingsOverlay.tsx msgid "Choose a bot…" msgstr "Bot wählen…" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index 4715b6b..ebd4e55 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -1095,6 +1095,10 @@ msgstr "Cheeks" msgid "Chef's hat" msgstr "Chef's hat" +#: src/components/onboarding/OnboardingDiscoveryShell.tsx +msgid "Chief of Staff" +msgstr "Chief of Staff" + #: src/pages/MessagingSettingsOverlay.tsx msgid "Choose a bot…" msgstr "Choose a bot…" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index ca5c21f..8b196d4 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -1095,6 +1095,10 @@ msgstr "" msgid "Chef's hat" msgstr "" +#: src/components/onboarding/OnboardingDiscoveryShell.tsx +msgid "Chief of Staff" +msgstr "" + #: src/pages/MessagingSettingsOverlay.tsx msgid "Choose a bot…" msgstr "Elige un bot…" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index 11b0f05..51bfffd 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -1095,6 +1095,10 @@ msgstr "" msgid "Chef's hat" msgstr "" +#: src/components/onboarding/OnboardingDiscoveryShell.tsx +msgid "Chief of Staff" +msgstr "" + #: src/pages/MessagingSettingsOverlay.tsx msgid "Choose a bot…" msgstr "" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index b4b731e..a927ff7 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -1095,6 +1095,10 @@ msgstr "" msgid "Chef's hat" msgstr "" +#: src/components/onboarding/OnboardingDiscoveryShell.tsx +msgid "Chief of Staff" +msgstr "" + #: src/pages/MessagingSettingsOverlay.tsx msgid "Choose a bot…" msgstr "Bot 선택…" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index 443bc2b..69732b9 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -1095,6 +1095,10 @@ msgstr "" msgid "Chef's hat" msgstr "" +#: src/components/onboarding/OnboardingDiscoveryShell.tsx +msgid "Chief of Staff" +msgstr "" + #: src/pages/MessagingSettingsOverlay.tsx msgid "Choose a bot…" msgstr "" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index b504d10..2ffa8c0 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -1095,6 +1095,10 @@ msgstr "" msgid "Chef's hat" msgstr "" +#: src/components/onboarding/OnboardingDiscoveryShell.tsx +msgid "Chief of Staff" +msgstr "" + #: src/pages/MessagingSettingsOverlay.tsx msgid "Choose a bot…" msgstr "Выбрать бота…" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index 266f929..f6e9605 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -1095,6 +1095,10 @@ msgstr "" msgid "Chef's hat" msgstr "" +#: src/components/onboarding/OnboardingDiscoveryShell.tsx +msgid "Chief of Staff" +msgstr "" + #: src/pages/MessagingSettingsOverlay.tsx msgid "Choose a bot…" msgstr "" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index ea60fd4..a66a4d7 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -1095,6 +1095,10 @@ msgstr "" msgid "Chef's hat" msgstr "" +#: src/components/onboarding/OnboardingDiscoveryShell.tsx +msgid "Chief of Staff" +msgstr "" + #: src/pages/MessagingSettingsOverlay.tsx msgid "Choose a bot…" msgstr "选择 Bot…" diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 1b41bdf..c07a465 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -24,6 +24,11 @@ import { countConfiguredIntegrations, type OnboardingDiscoveryPhase, } from "../lib/onboarding-discovery"; +import { + clearOnboardingDraft, + persistOnboardingDraft, + readOnboardingDraft, +} from "../lib/onboarding-draft"; import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories"; import { onboardingProfileUpdate } from "../lib/onboarding-profile"; import { rpc, selectSpace } from "../lib/rpc"; @@ -70,6 +75,34 @@ export function OnboardingPage() { const [currentLocale, setCurrentLocale] = useState(() => resolveUiLocale()); const [languageChoice, setLanguageChoice] = useState(null); const activeUiLocale = languageChoice ?? currentLocale; + const draftRestoredRef = useRef(false); + + useEffect(() => { + if (draftRestoredRef.current) return; + draftRestoredRef.current = true; + const draft = readOnboardingDraft(); + if (!draft || draft.step === "bot") return; + setStep(draft.step); + setEmojiConfig(draft.emojiConfig); + setOoName(draft.ooName); + setLanguageChoice(draft.languageChoice); + setCurrentLocale(draft.currentLocale); + setSelectedVoiceId(draft.selectedVoiceId); + setVoiceSkipped(draft.voiceSkipped); + }, []); + + useEffect(() => { + if (step === "loading" || step === "bot") return; + persistOnboardingDraft({ + step, + emojiConfig, + ooName, + languageChoice, + currentLocale, + selectedVoiceId, + voiceSkipped, + }); + }, [step, emojiConfig, ooName, languageChoice, currentLocale, selectedVoiceId, voiceSkipped]); useEffect(() => { let cancelled = false; @@ -193,6 +226,7 @@ export function OnboardingPage() { .list() .then((navigation) => navigation.main) .catch(() => null); + clearOnboardingDraft(); navigate(main ? enterMainConversation(main) : `/app/${bot.id}`); } catch (err) { createStartedRef.current = false; diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index dd611fc..d377ba4 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -13,6 +13,7 @@ import type { MainConversationRef, Me, Routine, + RunStatus, Space, SpaceMemoryConfig, TaughtSkill, @@ -378,13 +379,15 @@ export function ShellPage() { const [sendError, setSendError] = useState(null); const [attachmentNotice, setAttachmentNotice] = useState(null); - // Safety watchdog: ensure sending state is never stuck indefinitely if an RPC hangs or errors out + // Safety watchdog: clear a stuck send only when no run is active (local models can take longer). useEffect(() => { if (!sending) return; const timer = setTimeout(() => { + const runs = activeThreadRuns(snapshotRef.current); + if (runs.some((run) => isActive(run.status as RunStatus))) return; sendsInFlight.current = 0; setSending(false); - }, 10_000); + }, 120_000); return () => clearTimeout(timer); }, [sending]); const fileInputRef = useRef(null); @@ -1621,7 +1624,7 @@ export function ShellPage() { [currentRuns], ); const transcriptRunning = workingRuns.length > 0; - const composerRunning = currentRuns.some((run) => isActive(run.status)); + const composerRunning = sending || currentRuns.some((run) => isActive(run.status as RunStatus)); const runError = threadRunError(activeSnapshot, dismissedRunErrorIds); const displayedRunError = !sendError ? runError : null; const displayedRunErrorId = displayedRunError ? (activeSnapshot?.run?.id ?? null) : null; diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx index 61dbad9..c0e3196 100644 --- a/apps/web/src/pages/shell/transcript.tsx +++ b/apps/web/src/pages/shell/transcript.tsx @@ -38,6 +38,7 @@ import { MediaCard } from "../../components/media/MediaCard"; import { MediaPlayer } from "../../components/media/MediaPlayer"; import { ThreadCard } from "../../components/ThreadCard"; import { SkillDraftCard } from "../../components/teach/SkillDraftCard"; +import { rosterDoingVerb } from "../../lib/agent-activity"; import type { ArtifactTarget } from "../../lib/artifact-open"; import { copyableMessageText } from "../../lib/message-text"; import { messageProviderLabel } from "../../lib/messaging"; @@ -138,10 +139,13 @@ export const Transcript = memo(function Transcript({ ); const transcriptSubordinate = encounterMode && voiceEncounterActive; const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined; + const primaryRunStatus = workingBots[0]?.status; + const statusVerb = rosterDoingVerb(primaryRunStatus); const workingLabel = - workingBotName != null && workingBotName !== "" + statusVerb ?? + (workingBotName != null && workingBotName !== "" ? t`${workingBotName} is working` - : t`Bots are working`; + : t`Bots are working`); const [quoteDraft, setQuoteDraft] = useState<{ message: ThreadMessage; text: string; @@ -501,7 +505,7 @@ export const Transcript = memo(function Transcript({ block.kind === "progress" && !isToolActivityBlock(block) && Boolean(block.text), ), ) ? ( - + ) : null} diff --git a/docs/PRODUCTION-READINESS.md b/docs/PRODUCTION-READINESS.md new file mode 100644 index 0000000..1571b7a --- /dev/null +++ b/docs/PRODUCTION-READINESS.md @@ -0,0 +1,164 @@ +# evOo-local production readiness (Phase 1) + +Audit of user journeys, automated coverage, known defects, and Phase 2 backlog. Scope: web app (`apps/web`), API, shared packages; mobile (`apps/mobile`) noted where CI does not cover it. + +## Product surfaces + +| Surface | Entry | Locales in CI | +|--------|--------|----------------| +| Desktop web | ≥768px viewport | EN + DE in targeted specs | +| Mobile web | 390×844, touch | EN + DE in targeted specs | +| API / Postgres journeys | `packages/testkit` | EN | +| Mobile native | Expo app | Maestro smoke only (not full matrix) | + +## User journey inventory + +Legend: **E2E** = Playwright under `apps/web/e2e/`; **Unit** = vitest in app/packages; **PG** = postgres journey in testkit; **Gap** = missing or partial automation. + +### First run & onboarding + +| Journey | Routes / UI | Desktop E2E | Mobile E2E | Unit/PG | Gaps | +|--------|-------------|-------------|------------|---------|------| +| Sign up | `/sign-up` | `golden`, `first-run-onboarding-ux` | same | auth PG | — | +| Look category & swipe | `/onboarding` emoji step | `onboarding-oo-swipe`, `onboarding-emoji-first` | swipe spec (desktop viewport) | — | Mobile-specific swipe gestures | +| Name Oo | name step | `first-run-onboarding-ux` | partial | — | — | +| Language | language step | `first-run-onboarding-ux`, `ui-locale` | DE skip label | — | — | +| Voice | voice step | `first-run-onboarding-ux`, `voice` | DE voices | — | Kokoro offline errors | +| Integrations / scan | optional steps | `onboarding-discovery`, `onboarding-complete` | — | onboarding PG | — | +| Reload mid-onboarding | `/onboarding` | **`onboarding-reload-persistence`** (this PR) | same | `onboarding-draft.test` | — | +| Intake cards (focus, goal, rhythm, quiet hours) | `/app/g/…` | `onboarding-intake` | **`onboarding-intake-de`** | onboarding-intake PG | Full DE rhythm/quiet copy | +| Deep link `/onboarding` while signed in | redirect rules | partial via auth specs | — | — | Explicit deep-link matrix | + +### Session & auth + +| Journey | Routes | E2E | Gaps | +|--------|--------|-----|------| +| Sign in / out | `/sign-in`, `/` | `auth-lifecycle`, `browser-auth` | Tailscale / cookie edge cases | +| Password reset | `/forgot-password`, `/reset-password` | `auth-lifecycle` | — | +| Session unavailable / retry | `/app` loading | `module-recovery` | — | +| Welcome (signed out) | `/` | `smoke` | — | + +### Heute (daily brief) + +| Journey | Route | E2E | Gaps | +|--------|-------|-----|------| +| Open Heute | `/app/heute` | `heute-brief` | Mobile layout overflow audit | +| Coach / goals list | Heute UI | `heute-brief` | — | +| Deep link reload | `/app/heute` | partial | Console errors on cold load | + +### 1:1 & group chat + +| Journey | Route | E2E | Gaps | +|--------|-------|-----|------| +| Main conversation | `/app/g/:groupId` | `main-conversation`, `first-conversation` | — | +| Bot thread | `/app/:botId` | many specs | — | +| Send message | composer | `message-send` | — | +| Streaming / progress | transcript | `chat-progress-messages` (fixture), **`chat-queued-progress`** | Real LLM latency (mocked) | +| Queued / slow model UI | composer + transcript | **`chat-queued-progress`** | Multi-run queue depth | +| Errors & retry | composer banner | `run-failure`, `computer-screen-error` | — | +| Stop run | composer | `golden` | — | +| Peer / group | `/app/g/…` | `group-chats`, `peer-messages` | — | +| Console cleanliness | main paths | **`journey-console-errors`** | Full matrix all routes | + +### Oos (bots) + +| Journey | E2E | Gaps | +|--------|-----|------| +| Create / name / archive / delete | `bot-crud`, `new-bot-ux` | — | +| Switch bot / sidebar | `sidebar-nav`, `unterhaltungen-sidebar` | — | +| Avatar / look | `oo-avatar`, `look-suggestion` | — | +| Capabilities | `bot-capabilities` | — | +| Team vs dedicated computer | `team-computer` | Real docker teardown in PG | + +### Projekte + +| Journey | E2E | Gaps | +|--------|-----|------| +| Create project space | `spaces`, `adaptive-workspace` | — | +| Bind folder + git | `project-main-agent` | — | +| Timeline / goals | `project-main-agent` | Voice-first goal card | +| Empty project | partial | Dedicated empty-state spec | + +### Settings + +| Journey | E2E | Gaps | +|--------|-----|------| +| General / account | `settings-shell`, `user-profile` | — | +| Language (UI locale) | `ui-locale` | — | +| Voice | `voice`, `media-settings` | — | +| Goal / intake copy (DE) | **`onboarding-intake-de`** | Profile fact *values* remain EN from server | +| Privacy / delete account | `settings-privacy` | Docker/network cleanup verified in adapters unit; PG journey extension | +| Model connection | `model-settings`, `models-managed` | Offline model banner | +| Notifications / quiet hours | `notification-settings` | — | +| Messaging | `messaging-settings` | — | + +### Account & space deletion + +| Journey | E2E / PG | Gaps | +|--------|----------|------| +| Delete account (API) | PG journey 11 | **Team sandbox + `data/homes/team-*`** cleanup added in API (this PR); PG assertion Phase 2 | +| Delete empty space | authorization PG | — | + +### Empty, error, offline + +| Journey | Coverage | Gaps | +|--------|------------|------| +| No model connected | `model-settings`, composer banner | — | +| Server unreachable | `SessionUnavailable` UI | E2E | +| Empty inbox / no bots | partial | — | +| Teaching blocks composer | Shell copy | E2E | + +### Navigation, keyboard, a11y + +| Journey | E2E | Gaps | +|--------|-----|------| +| Escape closes mobile drawer | `first-run-onboarding-ux` mobile | — | +| Command palette | `command-palette` | — | +| Mention picker keyboard | `mention-picker-keyboard` | — | +| Collapsible sidebar | `collapsible-sidebar-sections`, `mobile-sidebar-swipe` | Touch target audit (44px) | +| Focus management on drawer close | mobile esc test | Broader focus trap review | + +### Integrations & MCP + +| Journey | E2E | Gaps | +|--------|-----|------| +| Integration setup | `integration-setup` | — | +| MCP OAuth callback | `mcp-oauth` | — | +| GraphQL integrations | `graphql-integrations` | — | + +## Prioritized defect list + +Severity: **P0** ship blocker · **P1** major UX/trust · **P2** polish · **P3** nice-to-have + +### Fixed in Phase 1 (this PR) + +| ID | Sev | Issue | Fix | +|----|-----|-------|-----| +| PR1-1 | P1 | Reload during onboarding lost look/name/step | Session draft via `onboarding-draft.ts` | +| PR1-2 | P1 | DE UI: goal placeholder / discovery “Chief of Staff” English | Lingui for discovery; intake DE E2E | +| PR1-3 | P1 | Account delete left team sandbox + homes | `destroySpaceSandboxes` on account delete | +| PR1-4 | P1 | Chat felt idle while queued / slow send | Composer `sending`+run chrome; queued label; 120s watchdog | +| PR1-5 | P2 | Console errors on main journeys | `journey-console-errors` spec | + +### Remaining — Phase 2+ + +| ID | Sev | Issue | Notes | +|----|-----|-------|-------| +| R2-1 | P1 | Profile fact *values* (e.g. focus goal text) stored/displayed in English in DE UI | Server-side localization or locale-aware fact rendering | +| R2-2 | P1 | PG journey: assert filesystem/docker cleanup after account delete | Extend journey 11 with fake sandbox + temp data dir | +| R2-3 | P2 | Five persona-test console/network errors (root causes) | Need symbolicated list from persona run logs | +| R2-4 | P2 | 390px layout overflow on long German strings | Audit Heute, settings, project timeline | +| R2-5 | P2 | Touch targets <44px in dense sidebar rows | Measure `sidebar-unterhaltungen` rows | +| R2-6 | P2 | Deep link every route while signed out/in | Table-driven Playwright matrix | +| R2-7 | P2 | Offline / model-not-connected on mobile web | No E2E | +| R2-8 | P3 | Mobile native parity for onboarding reload | Expo secure store draft | +| R2-9 | P3 | Intake card English questions in non-EN tests | Extend locale matrix beyond DE | +| R2-10 | P3 | NPS/SUS follow-ups from persona round 2 | Product research, not CI | + +## CI gates (Product) + +Lint · Typecheck · Unit · Postgres journeys · Production builds · Web E2E (Playwright). Desktop Electron E2E runs in CI virtual display only. + +## How to extend this doc + +When adding a journey spec, update the table row and move defects from **Remaining** to **Fixed** with PR link. Keep user-visible copy quoted in PR descriptions when introducing new strings. diff --git a/packages/adapters/src/child-bots.test.ts b/packages/adapters/src/child-bots.test.ts index f164116..f792398 100644 --- a/packages/adapters/src/child-bots.test.ts +++ b/packages/adapters/src/child-bots.test.ts @@ -12,6 +12,7 @@ import { archiveSpawnedBot, confirmSpawnedBotName, destroyBot, + destroySpaceSandboxes, spawnBot, } from "./child-bots.js"; @@ -614,6 +615,39 @@ describe("destroyBot", () => { }); }); +describe("destroySpaceSandboxes", () => { + it("destroys every provider-backed computer in the space", async () => { + const destroy = vi.fn().mockResolvedValue(undefined); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const prisma = { + computer: { + findMany: vi.fn().mockResolvedValue([ + { + id: "team-1", + homeKey: "team-space-1", + kind: "fake", + providerRef: "prov-team", + }, + ]), + updateMany, + }, + } as unknown as PrismaClient; + await destroySpaceSandboxes( + { + prisma, + sandbox: { destroy } as unknown as SandboxProvider, + home: { resolvePath: vi.fn() } as unknown as AgentHomeStore, + jobs: { cancel: vi.fn() } as unknown as JobPublisher, + dataDir: "/tmp/rakazo-space-teardown", + }, + { spaceId: "space-1" }, + context, + ); + expect(destroy).toHaveBeenCalledOnce(); + expect(updateMany).toHaveBeenCalledOnce(); + }); +}); + describe("archiveBot", () => { it("stops work and routines while preserving the bot", async () => { const updateBot = vi.fn().mockResolvedValue({}); diff --git a/packages/adapters/src/child-bots.ts b/packages/adapters/src/child-bots.ts index 78a1506..430505e 100644 --- a/packages/adapters/src/child-bots.ts +++ b/packages/adapters/src/child-bots.ts @@ -1,4 +1,3 @@ -import { rm } from "node:fs/promises"; import type { AdapterContext, AgentHomeStore, @@ -22,7 +21,7 @@ import { import { getLogger } from "@rakazo/logging"; import { toComputerRef } from "./computer-support.js"; import { checkpointAndRecordComputerWorkspace } from "./computer-workspace.js"; -import { resolveAgentHomePath } from "./home.js"; +import { removeAgentHomeDirectory } from "./home.js"; import { removePiBotSessions } from "./pi-session.js"; export function confirmSpawnedBotName(confirmName: string, botName: string) { @@ -337,6 +336,32 @@ export async function archiveBot( } } +/** Tear down every sandbox and home folder still tied to a space (e.g. shared team computers). */ +export async function destroySpaceSandboxes( + deps: BotLifecycleDeps, + input: { spaceId: string }, + context: AdapterContext, +) { + const computers = await deps.prisma.computer.findMany({ + where: { spaceId: input.spaceId, providerRef: { not: null } }, + select: { id: true, homeKey: true, kind: true, providerRef: true }, + }); + for (const computer of computers) { + if (!computer.providerRef) continue; + const ref = toComputerRef(computer); + await deps.sandbox.destroy(ref, { ...context, botId: computer.homeKey }).catch(() => undefined); + await removeAgentHomeDirectory(deps.home, computer.homeKey, deps.dataDir ?? "./data").catch( + () => undefined, + ); + } + if (computers.length) { + await deps.prisma.computer.updateMany({ + where: { spaceId: input.spaceId, id: { in: computers.map((computer) => computer.id) } }, + data: { state: "stopped", providerRef: null }, + }); + } +} + export async function destroyBot( deps: BotLifecycleDeps, bot: LifecycleBot, @@ -456,10 +481,9 @@ export async function destroyBot( }), ); if (dedicated) { - await rm(resolveAgentHomePath(deps.home, dedicated.homeKey, deps.dataDir ?? "./data"), { - recursive: true, - force: true, - }).catch(() => undefined); + await removeAgentHomeDirectory(deps.home, dedicated.homeKey, deps.dataDir ?? "./data").catch( + () => undefined, + ); } const artifactStore = deps.artifacts; if (artifactStore) { diff --git a/packages/adapters/src/home.test.ts b/packages/adapters/src/home.test.ts index 825287b..a327e0c 100644 --- a/packages/adapters/src/home.test.ts +++ b/packages/adapters/src/home.test.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "n import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { LocalAgentHomeStore } from "./home.js"; +import { LocalAgentHomeStore, removeAgentHomeDirectory, resolveSafeAgentHomePath } from "./home.js"; const context = { operationId: "test", @@ -164,3 +164,25 @@ describe("LocalAgentHomeStore path containment", () => { expect(await readFile(path.join(store.pathFor("bot-2"), "nested/run"))).toEqual(bytes); }); }); + +describe("resolveSafeAgentHomePath", () => { + it("rejects empty, root-like, and traversal home keys", async () => { + const root = await mkdtemp(path.join(tmpdir(), "rakazo-home-guard-")); + dirs.push(root); + const home = new LocalAgentHomeStore(root); + for (const homeKey of ["", ".", "..", "../etc", "team/../escape"]) { + expect(() => resolveSafeAgentHomePath(home, homeKey, root)).toThrow( + /Invalid agent home key|escapes/, + ); + } + }); + + it("removes only directories under homes/", async () => { + const root = await mkdtemp(path.join(tmpdir(), "rakazo-home-guard-rm-")); + dirs.push(root); + const home = new LocalAgentHomeStore(root); + const target = resolveSafeAgentHomePath(home, "bot-safe", root); + await removeAgentHomeDirectory(home, "bot-safe", root); + expect(target.startsWith(path.join(root, "homes"))).toBe(true); + }); +}); diff --git a/packages/adapters/src/home.ts b/packages/adapters/src/home.ts index 894f3a8..55352bc 100644 --- a/packages/adapters/src/home.ts +++ b/packages/adapters/src/home.ts @@ -224,6 +224,33 @@ export function resolveAgentHomePath(home: AgentHomeStore, homeKey: string, data return path.resolve(dataDir, "homes", homeKey); } +function assertValidHomeKey(homeKey: string) { + if (!homeKey || homeKey === "." || homeKey === ".." || path.basename(homeKey) !== homeKey) { + throw new Error("Invalid agent home key"); + } +} + +/** Resolved path must stay under `/homes/`; rejects traversal and empty keys. */ +export function resolveSafeAgentHomePath( + home: AgentHomeStore, + homeKey: string, + dataDir = "./data", +) { + assertValidHomeKey(homeKey); + const homesRoot = path.resolve(dataDir, "homes"); + const target = path.resolve(resolveAgentHomePath(home, homeKey, dataDir)); + assertContained(homesRoot, target); + return target; +} + +export async function removeAgentHomeDirectory( + home: AgentHomeStore, + homeKey: string, + dataDir = "./data", +) { + await rm(resolveSafeAgentHomePath(home, homeKey, dataDir), { recursive: true, force: true }); +} + function safeJoin(root: string, rel: string) { const resolvedRoot = path.resolve(root); const resolved = path.resolve(resolvedRoot, `.${path.sep}${rel.replace(/^\/+/, "")}`); diff --git a/packages/db/src/account-delete.test.ts b/packages/db/src/account-delete.test.ts new file mode 100644 index 0000000..8ee9b97 --- /dev/null +++ b/packages/db/src/account-delete.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { spaceIdsForSandboxTeardownAfterAccountDelete } from "./account-delete.js"; +import type { PrismaClient } from "./client.js"; + +describe("spaceIdsForSandboxTeardownAfterAccountDelete", () => { + it("includes solely-owned spaces and skips shared ones", async () => { + const findUnique = vi + .fn() + .mockImplementation(async ({ where }: { where: { spaceId_userId: { spaceId: string } } }) => { + const { spaceId } = where.spaceId_userId; + if (spaceId === "solo") return { role: "owner" }; + if (spaceId === "shared") return { role: "owner" }; + if (spaceId === "member-only") return { role: "member" }; + return null; + }); + const countMembers = vi.fn(async ({ where }: { where: { spaceId: string } }) => { + if (where.spaceId === "shared") return 1; + return 0; + }); + const countBots = vi.fn(async ({ where }: { where: { spaceId: string } }) => { + if (where.spaceId === "shared") return 1; + return 0; + }); + const prisma = { + spaceMember: { findUnique, count: countMembers }, + bot: { count: countBots }, + } as unknown as PrismaClient; + + const result = await spaceIdsForSandboxTeardownAfterAccountDelete(prisma, { + userId: "user-1", + spaceIds: ["solo", "shared", "member-only", "solo"], + }); + + expect(result).toEqual(["solo"]); + }); +}); diff --git a/packages/db/src/account-delete.ts b/packages/db/src/account-delete.ts new file mode 100644 index 0000000..ee07ca8 --- /dev/null +++ b/packages/db/src/account-delete.ts @@ -0,0 +1,32 @@ +import type { PrismaClient } from "./client.js"; + +type AccountDeleteDb = Pick; + +/** Spaces whose team sandboxes and homes may be torn down when this user deletes their account. */ +export async function spaceIdsForSandboxTeardownAfterAccountDelete( + prisma: AccountDeleteDb, + input: { userId: string; spaceIds: Iterable }, +): Promise { + const uniqueSpaceIds = [...new Set(input.spaceIds)]; + if (!uniqueSpaceIds.length) return []; + + const eligible: string[] = []; + for (const spaceId of uniqueSpaceIds) { + const membership = await prisma.spaceMember.findUnique({ + where: { spaceId_userId: { spaceId, userId: input.userId } }, + select: { role: true }, + }); + if (membership?.role !== "owner") continue; + + const [otherMembers, otherBots] = await Promise.all([ + prisma.spaceMember.count({ + where: { spaceId, userId: { not: input.userId } }, + }), + prisma.bot.count({ + where: { spaceId, userId: { not: input.userId } }, + }), + ]); + if (otherMembers === 0 && otherBots === 0) eligible.push(spaceId); + } + return eligible; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5e4e200..6378608 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,3 +1,4 @@ +export * from "./account-delete.js"; export * from "./autopilot-lines.js"; export * from "./bootstrap-user.js"; export * from "./cancel-runs.js"; diff --git a/packages/testkit/src/account-delete-sandbox.postgres.test.ts b/packages/testkit/src/account-delete-sandbox.postgres.test.ts new file mode 100644 index 0000000..aebf2ce --- /dev/null +++ b/packages/testkit/src/account-delete-sandbox.postgres.test.ts @@ -0,0 +1,182 @@ +import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposioEmulator, type FakeSandboxProvider } from "@rakazo/adapters"; +import type { Bot, Space } from "@rakazo/contracts"; +import { computerHomeKey, ensureComputerRecord } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Promise }; +type AppHandles = Awaited>; + +const databaseAvailable = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const fixtureOrigin = "http://127.0.0.1:5173"; + +describe.skipIf(!databaseAvailable)("account delete and team sandbox teardown", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-account-delete-sandbox-")); + + beforeAll(async () => { + const { createApp } = await import("../../../apps/api/src/app.ts"); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + composio: new ComposioEmulator(), + }); + app = handles.app; + }); + + afterAll(async () => { + await handles.stop?.(); + }); + + async function seedTeamComputer(spaceId: string, userId: string) { + const homeKey = computerHomeKey("team", spaceId); + const computer = await ensureComputerRecord(handles.prisma, { + mode: "team", + spaceId, + userId, + kind: "fake", + }); + const homePath = path.join(dataDir, "homes", homeKey); + mkdirSync(path.join(homePath, "marker"), { recursive: true }); + const sandbox = handles.sandbox as FakeSandboxProvider; + const ref = await sandbox.provision( + { botId: homeKey, homePath }, + { + operationId: "account-delete-seed", + traceId: "account-delete-seed", + spaceId, + userId, + signal: AbortSignal.timeout(15_000), + }, + ); + await handles.prisma.computer.update({ + where: { id: computer.id }, + data: { providerRef: ref.id, state: "running" }, + }); + return { homeKey, homePath, refId: ref.id, sandbox }; + } + + async function deleteAccount(cookie: string) { + const deleted = await app.request("/api/auth/delete-user", { + method: "POST", + headers: { + "content-type": "application/json", + cookie, + origin: fixtureOrigin, + }, + body: JSON.stringify({ password: "password12" }), + }); + expect(deleted.status).toBe(200); + } + + it("preserves shared team sandboxes when a non-owner member deletes their account", async () => { + const owner = await signup(app, `shared-owner-${stamp}@rakazo.test`, "Shared Owner"); + const member = await signup(app, `shared-member-${stamp}@rakazo.test`, "Shared Member"); + const shared = await rpc(app, owner, "spaces/create", { name: "Shared teardown" }); + const sharedRow = await handles.prisma.space.findUniqueOrThrow({ + where: { id: shared.id }, + select: { organizationId: true }, + }); + const memberActor = await rpc<{ userId: string }>(app, member, "me"); + await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); + await handles.prisma.member.create({ + data: { + id: `shared-org-member-${stamp}`, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + await handles.prisma.spaceMember.create({ + data: { + id: `shared-space-member-${stamp}`, + spaceId: shared.id, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + + await rpc(app, owner, "bots/create", botPayload("Owner Bot"), shared.id); + await rpc(app, member, "bots/create", botPayload("Member Bot"), shared.id); + const { homePath, refId, sandbox } = await seedTeamComputer(shared.id, memberActor.userId); + + await deleteAccount(member); + + expect(existsSync(homePath)).toBe(true); + expect(sandbox.boxes.has(refId)).toBe(true); + const computer = await handles.prisma.computer.findFirst({ + where: { spaceId: shared.id, scope: "team" }, + }); + expect(computer?.providerRef).toBeTruthy(); + }); + + it("tears down team sandboxes when the sole owner deletes their account", async () => { + const cookie = await signup(app, `solo-delete-${stamp}@rakazo.test`, "Solo Delete"); + const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); + const bot = await rpc(app, cookie, "bots/create", botPayload("Temporary")); + const { homePath, refId, sandbox } = await seedTeamComputer(bot.spaceId, me.userId); + + await deleteAccount(cookie); + + expect(existsSync(homePath)).toBe(false); + expect(sandbox.boxes.has(refId)).toBe(false); + }); +}); + +function botPayload(name: string) { + return { + name, + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }; +} + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: fixtureOrigin }, + body: JSON.stringify({ email, password: "password12", name }), + }); + expect(response.status).toBeLessThan(400); + return sessionCookieHeader(response); +} + +async function rpc( + app: App, + cookie: string, + procedure: string, + input: unknown = {}, + spaceId?: string, +): Promise { + const headers: Record = { + "content-type": "application/json", + cookie, + origin: fixtureOrigin, + }; + if (spaceId) headers["x-rakazo-space-id"] = spaceId; + const response = await app.request(`/rpc/${procedure}`, { + method: "POST", + headers, + body: JSON.stringify({ json: input }), + }); + const body = (await response.json()) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || body.error) { + throw new Error(`${procedure}: ${body.error?.message ?? response.status}`); + } + return body.json as T; +} diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index da71092..747bc9d 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -16,9 +16,11 @@ import { import { ACTIVE_RUN_STATUSES, ONCE_ROUTINE_CRON } from "@rakazo/core"; import { appendEvent, + computerHomeKey, createRepos, createThreadEvents, createThreadMessage, + ensureComputerRecord, ensureMainBot, joinMainConversation, RunHistoryWriteError, @@ -1798,6 +1800,30 @@ describeJourneys("required product journeys", () => { instructions: "", notifyOnFinish: true, }); + const homeKey = computerHomeKey("team", bot.spaceId); + const teamComputer = await ensureComputerRecord(prisma, { + mode: "team", + spaceId: bot.spaceId, + userId: me.userId, + kind: "fake", + }); + const teamHomePath = path.join(dataDir, "homes", homeKey); + mkdirSync(path.join(teamHomePath, "account-delete-marker"), { recursive: true }); + const fakeSandbox = sandbox as FakeSandboxProvider; + const teamRef = await fakeSandbox.provision( + { botId: homeKey, homePath: teamHomePath }, + { + operationId: "journey-11-seed", + traceId: "journey-11-seed", + spaceId: bot.spaceId, + userId: me.userId, + signal: AbortSignal.timeout(15_000), + }, + ); + await prisma.computer.update({ + where: { id: teamComputer.id }, + data: { providerRef: teamRef.id, state: "running" }, + }); const deleted = await app.request("/api/auth/delete-user", { method: "POST", @@ -1814,6 +1840,8 @@ describeJourneys("required product journeys", () => { expect(await prisma.organization.findUnique({ where: { id: me.spaceId } })).toBeNull(); expect(await prisma.bot.findUnique({ where: { id: bot.id } })).toBeNull(); expect((await raw(app, cookie, "me")).status).toBeGreaterThanOrEqual(400); + expect(existsSync(teamHomePath)).toBe(false); + expect(fakeSandbox.boxes.has(teamRef.id)).toBe(false); }); it("12: a bot can spawn a regular bot and must confirm the name to delete it", async () => {