Compare commits
No commits in common. "18ace6b1c540e5e3fa142b99d1d709233cba97c4" and "0e1036b2ffc88f62c68555704032b031d66ef46e" have entirely different histories.
18ace6b1c5
...
0e1036b2ff
@ -35,7 +35,6 @@ import {
|
|||||||
createRunSecretWriter,
|
createRunSecretWriter,
|
||||||
createWebProvider,
|
createWebProvider,
|
||||||
destroyBot,
|
destroyBot,
|
||||||
destroySpaceSandboxes,
|
|
||||||
EmailEmulator,
|
EmailEmulator,
|
||||||
EncryptedSecretStore,
|
EncryptedSecretStore,
|
||||||
ExpoPushSender,
|
ExpoPushSender,
|
||||||
@ -84,7 +83,6 @@ import {
|
|||||||
parsePositiveInteger,
|
parsePositiveInteger,
|
||||||
provisionMessagingIdentity,
|
provisionMessagingIdentity,
|
||||||
requireMembership,
|
requireMembership,
|
||||||
spaceIdsForSandboxTeardownAfterAccountDelete,
|
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
import type { Logger } from "@rakazo/logging";
|
import type { Logger } from "@rakazo/logging";
|
||||||
import {
|
import {
|
||||||
@ -367,38 +365,23 @@ export async function createApp(
|
|||||||
where: { userId },
|
where: { userId },
|
||||||
select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true },
|
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(
|
await Promise.all(
|
||||||
bots.map((bot) =>
|
bots.map((bot) =>
|
||||||
destroyBot(
|
destroyBot(
|
||||||
lifecycleDeps,
|
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
|
||||||
bot,
|
bot,
|
||||||
{ ...deleteContext, spaceId: bot.spaceId, botId: bot.id },
|
{
|
||||||
|
operationId: `account-delete:${userId}`,
|
||||||
|
traceId: `account-delete:${userId}`,
|
||||||
|
spaceId: bot.spaceId,
|
||||||
|
userId,
|
||||||
|
botId: bot.id,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
},
|
||||||
{ deleteMemories: true },
|
{ 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);
|
await removePiUserSessions(env.dataDir, userId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web";
|
import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { isDoingStatus } from "../../lib/agent-activity";
|
|
||||||
import { personaWorkingLinesPlain, resolveBotPersona } from "../../lib/bot-persona";
|
import { personaWorkingLinesPlain, resolveBotPersona } from "../../lib/bot-persona";
|
||||||
import { Shimmer } from "./primitives";
|
import { Shimmer } from "./primitives";
|
||||||
|
|
||||||
@ -36,17 +35,8 @@ export function CollaborationMarker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveBotGlyph({
|
export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; label: string }) {
|
||||||
bots,
|
|
||||||
label,
|
|
||||||
runStatus,
|
|
||||||
}: {
|
|
||||||
bots: GroupAvatarMember[];
|
|
||||||
label: string;
|
|
||||||
runStatus?: string;
|
|
||||||
}) {
|
|
||||||
const firstBot = bots[0];
|
const firstBot = bots[0];
|
||||||
const status = runStatus ?? firstBot?.status;
|
|
||||||
const persona = useMemo(
|
const persona = useMemo(
|
||||||
() =>
|
() =>
|
||||||
firstBot?.name
|
firstBot?.name
|
||||||
@ -60,9 +50,8 @@ export function ActiveBotGlyph({
|
|||||||
);
|
);
|
||||||
const workingLines = useMemo(() => {
|
const workingLines = useMemo(() => {
|
||||||
if (!firstBot?.name || !persona) return [label];
|
if (!firstBot?.name || !persona) return [label];
|
||||||
if (isDoingStatus(status) && status !== "running") return [label];
|
|
||||||
return [...personaWorkingLinesPlain(persona.archetype, firstBot.name)];
|
return [...personaWorkingLinesPlain(persona.archetype, firstBot.name)];
|
||||||
}, [firstBot?.name, label, persona, status]);
|
}, [firstBot?.name, label, persona]);
|
||||||
const [phraseIndex, setPhraseIndex] = useState(0);
|
const [phraseIndex, setPhraseIndex] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { Trans, useLingui } from "@lingui/react/macro";
|
|||||||
import { EvooMascot } from "@rakazo/ui-web";
|
import { EvooMascot } from "@rakazo/ui-web";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
DISCOVERY_MAIN_BOT_ROLE,
|
|
||||||
type DiscoveryItem,
|
type DiscoveryItem,
|
||||||
type DiscoverySourceId,
|
type DiscoverySourceId,
|
||||||
discoveryProgress,
|
discoveryProgress,
|
||||||
@ -84,14 +83,6 @@ export function OnboardingDiscoveryShell({
|
|||||||
{active ? (
|
{active ? (
|
||||||
<>
|
<>
|
||||||
{sourceLabel(active.id, t)}
|
{sourceLabel(active.id, t)}
|
||||||
{active.status === "found" && active.detail === DISCOVERY_MAIN_BOT_ROLE ? (
|
|
||||||
<>
|
|
||||||
{" · "}
|
|
||||||
<Trans>Chief of Staff</Trans>
|
|
||||||
</>
|
|
||||||
) : active.detail && active.detail !== DISCOVERY_MAIN_BOT_ROLE ? (
|
|
||||||
<> · {active.detail}</>
|
|
||||||
) : null}
|
|
||||||
{active.status === "scanning" ? "…" : null}
|
{active.status === "scanning" ? "…" : null}
|
||||||
</>
|
</>
|
||||||
) : done >= total && total > 0 ? (
|
) : done >= total && total > 0 ? (
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { EvooPose } from "@rakazo/ui-web";
|
import type { EvooPose } from "@rakazo/ui-web";
|
||||||
import { BotAvatar, Button } from "@rakazo/ui-web";
|
import { BotAvatar, Button } from "@rakazo/ui-web";
|
||||||
import { useEffect, useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import type { OnboardingEmojiConfig } from "./OnboardingOoDesigner";
|
import type { OnboardingEmojiConfig } from "./OnboardingOoDesigner";
|
||||||
|
|
||||||
export const DEFAULT_FIRST_OO_NAME = "Chief";
|
export const DEFAULT_FIRST_OO_NAME = "Chief";
|
||||||
@ -25,10 +25,6 @@ export function OnboardingNameStep({
|
|||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
const [draft, setDraft] = useState(name);
|
const [draft, setDraft] = useState(name);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDraft(name);
|
|
||||||
}, [name]);
|
|
||||||
|
|
||||||
function commitAndContinue() {
|
function commitAndContinue() {
|
||||||
const trimmed = draft.trim() || DEFAULT_FIRST_OO_NAME;
|
const trimmed = draft.trim() || DEFAULT_FIRST_OO_NAME;
|
||||||
onNameChange(trimmed);
|
onNameChange(trimmed);
|
||||||
@ -66,11 +62,7 @@ export function OnboardingNameStep({
|
|||||||
autoCapitalize="words"
|
autoCapitalize="words"
|
||||||
placeholder={DEFAULT_FIRST_OO_NAME}
|
placeholder={DEFAULT_FIRST_OO_NAME}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => {
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
const value = event.target.value;
|
|
||||||
setDraft(value);
|
|
||||||
onNameChange(value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter") commitAndContinue();
|
if (event.key === "Enter") commitAndContinue();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import type { IntegrationSetupState, Me } from "@rakazo/contracts";
|
import type { IntegrationSetupState, Me } from "@rakazo/contracts";
|
||||||
import { collectLocalProfileSignals, type UserProfileSignals } from "./user-profile-harvest";
|
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 DiscoverySourceId = "profile-basics" | "connected-tools" | "specialist-crew";
|
||||||
|
|
||||||
export type DiscoveryStatus = "pending" | "scanning" | "found" | "skipped";
|
export type DiscoveryStatus = "pending" | "scanning" | "found" | "skipped";
|
||||||
@ -84,7 +81,7 @@ export function computeDiscoveryItems(context: DiscoveryContext): DiscoveryItem[
|
|||||||
|
|
||||||
const crew: DiscoveryItem = (() => {
|
const crew: DiscoveryItem = (() => {
|
||||||
if (context.crewReady) {
|
if (context.crewReady) {
|
||||||
return { id: "specialist-crew", status: "found", detail: DISCOVERY_MAIN_BOT_ROLE };
|
return { id: "specialist-crew", status: "found", detail: "Chief of Staff" };
|
||||||
}
|
}
|
||||||
if (context.phase === "assembly") {
|
if (context.phase === "assembly") {
|
||||||
return { id: "specialist-crew", status: "scanning" };
|
return { id: "specialist-crew", status: "scanning" };
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
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<string, string>();
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
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<Storage, "getItem"> | null | undefined,
|
|
||||||
): Pick<Storage, "getItem"> | null {
|
|
||||||
if (storage !== undefined) return storage;
|
|
||||||
return typeof sessionStorage !== "undefined" ? sessionStorage : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeStorage(
|
|
||||||
storage: Pick<Storage, "setItem" | "removeItem"> | null | undefined,
|
|
||||||
): Pick<Storage, "setItem" | "removeItem"> | 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<Storage, "getItem"> | 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<OnboardingDraft>;
|
|
||||||
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<Storage, "setItem"> | 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<Storage, "removeItem"> | null = writeStorage(undefined),
|
|
||||||
): void {
|
|
||||||
if (!storage) return;
|
|
||||||
try {
|
|
||||||
storage.removeItem(ONBOARDING_DRAFT_STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
// Ignore storage failures on completion.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1095,10 +1095,6 @@ msgstr "Wangen"
|
|||||||
msgid "Chef's hat"
|
msgid "Chef's hat"
|
||||||
msgstr "Kochmütze"
|
msgstr "Kochmütze"
|
||||||
|
|
||||||
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
|
|
||||||
msgid "Chief of Staff"
|
|
||||||
msgstr "Stabschef"
|
|
||||||
|
|
||||||
#: src/pages/MessagingSettingsOverlay.tsx
|
#: src/pages/MessagingSettingsOverlay.tsx
|
||||||
msgid "Choose a bot…"
|
msgid "Choose a bot…"
|
||||||
msgstr "Bot wählen…"
|
msgstr "Bot wählen…"
|
||||||
|
|||||||
@ -1095,10 +1095,6 @@ msgstr "Cheeks"
|
|||||||
msgid "Chef's hat"
|
msgid "Chef's hat"
|
||||||
msgstr "Chef's hat"
|
msgstr "Chef's hat"
|
||||||
|
|
||||||
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
|
|
||||||
msgid "Chief of Staff"
|
|
||||||
msgstr "Chief of Staff"
|
|
||||||
|
|
||||||
#: src/pages/MessagingSettingsOverlay.tsx
|
#: src/pages/MessagingSettingsOverlay.tsx
|
||||||
msgid "Choose a bot…"
|
msgid "Choose a bot…"
|
||||||
msgstr "Choose a bot…"
|
msgstr "Choose a bot…"
|
||||||
|
|||||||
@ -1095,10 +1095,6 @@ msgstr ""
|
|||||||
msgid "Chef's hat"
|
msgid "Chef's hat"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
|
|
||||||
msgid "Chief of Staff"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/pages/MessagingSettingsOverlay.tsx
|
#: src/pages/MessagingSettingsOverlay.tsx
|
||||||
msgid "Choose a bot…"
|
msgid "Choose a bot…"
|
||||||
msgstr "Elige un bot…"
|
msgstr "Elige un bot…"
|
||||||
|
|||||||
@ -1095,10 +1095,6 @@ msgstr ""
|
|||||||
msgid "Chef's hat"
|
msgid "Chef's hat"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
|
|
||||||
msgid "Chief of Staff"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/pages/MessagingSettingsOverlay.tsx
|
#: src/pages/MessagingSettingsOverlay.tsx
|
||||||
msgid "Choose a bot…"
|
msgid "Choose a bot…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1095,10 +1095,6 @@ msgstr ""
|
|||||||
msgid "Chef's hat"
|
msgid "Chef's hat"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
|
|
||||||
msgid "Chief of Staff"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/pages/MessagingSettingsOverlay.tsx
|
#: src/pages/MessagingSettingsOverlay.tsx
|
||||||
msgid "Choose a bot…"
|
msgid "Choose a bot…"
|
||||||
msgstr "Bot 선택…"
|
msgstr "Bot 선택…"
|
||||||
|
|||||||
@ -1095,10 +1095,6 @@ msgstr ""
|
|||||||
msgid "Chef's hat"
|
msgid "Chef's hat"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
|
|
||||||
msgid "Chief of Staff"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/pages/MessagingSettingsOverlay.tsx
|
#: src/pages/MessagingSettingsOverlay.tsx
|
||||||
msgid "Choose a bot…"
|
msgid "Choose a bot…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1095,10 +1095,6 @@ msgstr ""
|
|||||||
msgid "Chef's hat"
|
msgid "Chef's hat"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
|
|
||||||
msgid "Chief of Staff"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/pages/MessagingSettingsOverlay.tsx
|
#: src/pages/MessagingSettingsOverlay.tsx
|
||||||
msgid "Choose a bot…"
|
msgid "Choose a bot…"
|
||||||
msgstr "Выбрать бота…"
|
msgstr "Выбрать бота…"
|
||||||
|
|||||||
@ -1095,10 +1095,6 @@ msgstr ""
|
|||||||
msgid "Chef's hat"
|
msgid "Chef's hat"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
|
|
||||||
msgid "Chief of Staff"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/pages/MessagingSettingsOverlay.tsx
|
#: src/pages/MessagingSettingsOverlay.tsx
|
||||||
msgid "Choose a bot…"
|
msgid "Choose a bot…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1095,10 +1095,6 @@ msgstr ""
|
|||||||
msgid "Chef's hat"
|
msgid "Chef's hat"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/OnboardingDiscoveryShell.tsx
|
|
||||||
msgid "Chief of Staff"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/pages/MessagingSettingsOverlay.tsx
|
#: src/pages/MessagingSettingsOverlay.tsx
|
||||||
msgid "Choose a bot…"
|
msgid "Choose a bot…"
|
||||||
msgstr "选择 Bot…"
|
msgstr "选择 Bot…"
|
||||||
|
|||||||
@ -24,11 +24,6 @@ import {
|
|||||||
countConfiguredIntegrations,
|
countConfiguredIntegrations,
|
||||||
type OnboardingDiscoveryPhase,
|
type OnboardingDiscoveryPhase,
|
||||||
} from "../lib/onboarding-discovery";
|
} from "../lib/onboarding-discovery";
|
||||||
import {
|
|
||||||
clearOnboardingDraft,
|
|
||||||
persistOnboardingDraft,
|
|
||||||
readOnboardingDraft,
|
|
||||||
} from "../lib/onboarding-draft";
|
|
||||||
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
|
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
|
||||||
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
||||||
import { rpc, selectSpace } from "../lib/rpc";
|
import { rpc, selectSpace } from "../lib/rpc";
|
||||||
@ -75,34 +70,6 @@ export function OnboardingPage() {
|
|||||||
const [currentLocale, setCurrentLocale] = useState<UiLocale>(() => resolveUiLocale());
|
const [currentLocale, setCurrentLocale] = useState<UiLocale>(() => resolveUiLocale());
|
||||||
const [languageChoice, setLanguageChoice] = useState<UiLocale | null>(null);
|
const [languageChoice, setLanguageChoice] = useState<UiLocale | null>(null);
|
||||||
const activeUiLocale = languageChoice ?? currentLocale;
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -226,7 +193,6 @@ export function OnboardingPage() {
|
|||||||
.list()
|
.list()
|
||||||
.then((navigation) => navigation.main)
|
.then((navigation) => navigation.main)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
clearOnboardingDraft();
|
|
||||||
navigate(main ? enterMainConversation(main) : `/app/${bot.id}`);
|
navigate(main ? enterMainConversation(main) : `/app/${bot.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
createStartedRef.current = false;
|
createStartedRef.current = false;
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import type {
|
|||||||
MainConversationRef,
|
MainConversationRef,
|
||||||
Me,
|
Me,
|
||||||
Routine,
|
Routine,
|
||||||
RunStatus,
|
|
||||||
Space,
|
Space,
|
||||||
SpaceMemoryConfig,
|
SpaceMemoryConfig,
|
||||||
TaughtSkill,
|
TaughtSkill,
|
||||||
@ -379,15 +378,13 @@ export function ShellPage() {
|
|||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
|
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
// Safety watchdog: clear a stuck send only when no run is active (local models can take longer).
|
// Safety watchdog: ensure sending state is never stuck indefinitely if an RPC hangs or errors out
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sending) return;
|
if (!sending) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const runs = activeThreadRuns(snapshotRef.current);
|
|
||||||
if (runs.some((run) => isActive(run.status as RunStatus))) return;
|
|
||||||
sendsInFlight.current = 0;
|
sendsInFlight.current = 0;
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}, 120_000);
|
}, 10_000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [sending]);
|
}, [sending]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -1624,7 +1621,7 @@ export function ShellPage() {
|
|||||||
[currentRuns],
|
[currentRuns],
|
||||||
);
|
);
|
||||||
const transcriptRunning = workingRuns.length > 0;
|
const transcriptRunning = workingRuns.length > 0;
|
||||||
const composerRunning = sending || currentRuns.some((run) => isActive(run.status as RunStatus));
|
const composerRunning = currentRuns.some((run) => isActive(run.status));
|
||||||
const runError = threadRunError(activeSnapshot, dismissedRunErrorIds);
|
const runError = threadRunError(activeSnapshot, dismissedRunErrorIds);
|
||||||
const displayedRunError = !sendError ? runError : null;
|
const displayedRunError = !sendError ? runError : null;
|
||||||
const displayedRunErrorId = displayedRunError ? (activeSnapshot?.run?.id ?? null) : null;
|
const displayedRunErrorId = displayedRunError ? (activeSnapshot?.run?.id ?? null) : null;
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import { MediaCard } from "../../components/media/MediaCard";
|
|||||||
import { MediaPlayer } from "../../components/media/MediaPlayer";
|
import { MediaPlayer } from "../../components/media/MediaPlayer";
|
||||||
import { ThreadCard } from "../../components/ThreadCard";
|
import { ThreadCard } from "../../components/ThreadCard";
|
||||||
import { SkillDraftCard } from "../../components/teach/SkillDraftCard";
|
import { SkillDraftCard } from "../../components/teach/SkillDraftCard";
|
||||||
import { rosterDoingVerb } from "../../lib/agent-activity";
|
|
||||||
import type { ArtifactTarget } from "../../lib/artifact-open";
|
import type { ArtifactTarget } from "../../lib/artifact-open";
|
||||||
import { copyableMessageText } from "../../lib/message-text";
|
import { copyableMessageText } from "../../lib/message-text";
|
||||||
import { messageProviderLabel } from "../../lib/messaging";
|
import { messageProviderLabel } from "../../lib/messaging";
|
||||||
@ -139,13 +138,10 @@ export const Transcript = memo(function Transcript({
|
|||||||
);
|
);
|
||||||
const transcriptSubordinate = encounterMode && voiceEncounterActive;
|
const transcriptSubordinate = encounterMode && voiceEncounterActive;
|
||||||
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
|
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
|
||||||
const primaryRunStatus = workingBots[0]?.status;
|
|
||||||
const statusVerb = rosterDoingVerb(primaryRunStatus);
|
|
||||||
const workingLabel =
|
const workingLabel =
|
||||||
statusVerb ??
|
workingBotName != null && workingBotName !== ""
|
||||||
(workingBotName != null && workingBotName !== ""
|
|
||||||
? t`${workingBotName} is working`
|
? t`${workingBotName} is working`
|
||||||
: t`Bots are working`);
|
: t`Bots are working`;
|
||||||
const [quoteDraft, setQuoteDraft] = useState<{
|
const [quoteDraft, setQuoteDraft] = useState<{
|
||||||
message: ThreadMessage;
|
message: ThreadMessage;
|
||||||
text: string;
|
text: string;
|
||||||
@ -505,7 +501,7 @@ export const Transcript = memo(function Transcript({
|
|||||||
block.kind === "progress" && !isToolActivityBlock(block) && Boolean(block.text),
|
block.kind === "progress" && !isToolActivityBlock(block) && Boolean(block.text),
|
||||||
),
|
),
|
||||||
) ? (
|
) ? (
|
||||||
<ActiveBotGlyph bots={workingBots} label={workingLabel} runStatus={primaryRunStatus} />
|
<ActiveBotGlyph bots={workingBots} label={workingLabel} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,164 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -12,7 +12,6 @@ import {
|
|||||||
archiveSpawnedBot,
|
archiveSpawnedBot,
|
||||||
confirmSpawnedBotName,
|
confirmSpawnedBotName,
|
||||||
destroyBot,
|
destroyBot,
|
||||||
destroySpaceSandboxes,
|
|
||||||
spawnBot,
|
spawnBot,
|
||||||
} from "./child-bots.js";
|
} from "./child-bots.js";
|
||||||
|
|
||||||
@ -615,39 +614,6 @@ 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", () => {
|
describe("archiveBot", () => {
|
||||||
it("stops work and routines while preserving the bot", async () => {
|
it("stops work and routines while preserving the bot", async () => {
|
||||||
const updateBot = vi.fn().mockResolvedValue({});
|
const updateBot = vi.fn().mockResolvedValue({});
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { rm } from "node:fs/promises";
|
||||||
import type {
|
import type {
|
||||||
AdapterContext,
|
AdapterContext,
|
||||||
AgentHomeStore,
|
AgentHomeStore,
|
||||||
@ -21,7 +22,7 @@ import {
|
|||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
import { toComputerRef } from "./computer-support.js";
|
import { toComputerRef } from "./computer-support.js";
|
||||||
import { checkpointAndRecordComputerWorkspace } from "./computer-workspace.js";
|
import { checkpointAndRecordComputerWorkspace } from "./computer-workspace.js";
|
||||||
import { removeAgentHomeDirectory } from "./home.js";
|
import { resolveAgentHomePath } from "./home.js";
|
||||||
import { removePiBotSessions } from "./pi-session.js";
|
import { removePiBotSessions } from "./pi-session.js";
|
||||||
|
|
||||||
export function confirmSpawnedBotName(confirmName: string, botName: string) {
|
export function confirmSpawnedBotName(confirmName: string, botName: string) {
|
||||||
@ -336,32 +337,6 @@ 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(
|
export async function destroyBot(
|
||||||
deps: BotLifecycleDeps,
|
deps: BotLifecycleDeps,
|
||||||
bot: LifecycleBot,
|
bot: LifecycleBot,
|
||||||
@ -481,9 +456,10 @@ export async function destroyBot(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (dedicated) {
|
if (dedicated) {
|
||||||
await removeAgentHomeDirectory(deps.home, dedicated.homeKey, deps.dataDir ?? "./data").catch(
|
await rm(resolveAgentHomePath(deps.home, dedicated.homeKey, deps.dataDir ?? "./data"), {
|
||||||
() => undefined,
|
recursive: true,
|
||||||
);
|
force: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
const artifactStore = deps.artifacts;
|
const artifactStore = deps.artifacts;
|
||||||
if (artifactStore) {
|
if (artifactStore) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "n
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { LocalAgentHomeStore, removeAgentHomeDirectory, resolveSafeAgentHomePath } from "./home.js";
|
import { LocalAgentHomeStore } from "./home.js";
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
operationId: "test",
|
operationId: "test",
|
||||||
@ -164,25 +164,3 @@ describe("LocalAgentHomeStore path containment", () => {
|
|||||||
expect(await readFile(path.join(store.pathFor("bot-2"), "nested/run"))).toEqual(bytes);
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -224,33 +224,6 @@ export function resolveAgentHomePath(home: AgentHomeStore, homeKey: string, data
|
|||||||
return path.resolve(dataDir, "homes", homeKey);
|
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 `<dataDir>/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) {
|
function safeJoin(root: string, rel: string) {
|
||||||
const resolvedRoot = path.resolve(root);
|
const resolvedRoot = path.resolve(root);
|
||||||
const resolved = path.resolve(resolvedRoot, `.${path.sep}${rel.replace(/^\/+/, "")}`);
|
const resolved = path.resolve(resolvedRoot, `.${path.sep}${rel.replace(/^\/+/, "")}`);
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import type { PrismaClient } from "./client.js";
|
|
||||||
|
|
||||||
type AccountDeleteDb = Pick<PrismaClient, "spaceMember" | "bot">;
|
|
||||||
|
|
||||||
/** 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<string> },
|
|
||||||
): Promise<string[]> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
export * from "./account-delete.js";
|
|
||||||
export * from "./autopilot-lines.js";
|
export * from "./autopilot-lines.js";
|
||||||
export * from "./bootstrap-user.js";
|
export * from "./bootstrap-user.js";
|
||||||
export * from "./cancel-runs.js";
|
export * from "./cancel-runs.js";
|
||||||
|
|||||||
@ -1,182 +0,0 @@
|
|||||||
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<Response> };
|
|
||||||
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
|
||||||
|
|
||||||
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<Space>(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<Bot>(app, owner, "bots/create", botPayload("Owner Bot"), shared.id);
|
|
||||||
await rpc<Bot>(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<Bot>(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<T>(
|
|
||||||
app: App,
|
|
||||||
cookie: string,
|
|
||||||
procedure: string,
|
|
||||||
input: unknown = {},
|
|
||||||
spaceId?: string,
|
|
||||||
): Promise<T> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"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;
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs";
|
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
@ -16,11 +16,9 @@ import {
|
|||||||
import { ACTIVE_RUN_STATUSES, ONCE_ROUTINE_CRON } from "@rakazo/core";
|
import { ACTIVE_RUN_STATUSES, ONCE_ROUTINE_CRON } from "@rakazo/core";
|
||||||
import {
|
import {
|
||||||
appendEvent,
|
appendEvent,
|
||||||
computerHomeKey,
|
|
||||||
createRepos,
|
createRepos,
|
||||||
createThreadEvents,
|
createThreadEvents,
|
||||||
createThreadMessage,
|
createThreadMessage,
|
||||||
ensureComputerRecord,
|
|
||||||
ensureMainBot,
|
ensureMainBot,
|
||||||
joinMainConversation,
|
joinMainConversation,
|
||||||
RunHistoryWriteError,
|
RunHistoryWriteError,
|
||||||
@ -1800,30 +1798,6 @@ describeJourneys("required product journeys", () => {
|
|||||||
instructions: "",
|
instructions: "",
|
||||||
notifyOnFinish: true,
|
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", {
|
const deleted = await app.request("/api/auth/delete-user", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -1840,8 +1814,6 @@ describeJourneys("required product journeys", () => {
|
|||||||
expect(await prisma.organization.findUnique({ where: { id: me.spaceId } })).toBeNull();
|
expect(await prisma.organization.findUnique({ where: { id: me.spaceId } })).toBeNull();
|
||||||
expect(await prisma.bot.findUnique({ where: { id: bot.id } })).toBeNull();
|
expect(await prisma.bot.findUnique({ where: { id: bot.id } })).toBeNull();
|
||||||
expect((await raw(app, cookie, "me")).status).toBeGreaterThanOrEqual(400);
|
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 () => {
|
it("12: a bot can spawn a regular bot and must confirm the name to delete it", async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user