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 () => {