diff --git a/.env.example b/.env.example index 3565c80..5e52003 100644 --- a/.env.example +++ b/.env.example @@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY= PUBLIC_POSTHOG_HOST=https://us.i.posthog.com SIGNUPS_ENABLED=true SIGNUP_ALLOWLIST= -# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem. -# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the -# deployment owner's own onboarding discover local projects. Other accounts never scan the host. +# First-run discovery. Leave unset and onboarding never looks at the host filesystem. +# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the +# git projects in it, reading file and folder names only. Other accounts never see the step. # ONBOARDING_SCAN_HOME= ENCRYPTION_KEY=replace-with-64-random-hex-characters DATA_DIR=./data @@ -166,8 +166,12 @@ SMTP_URL= EMAIL_FROM= # Local development only: capture password-reset email and print its link to the API console. EMAIL_EMULATOR= +# Optional web push keys (set both). Unset: generated once and stored encrypted in the database. +# Browsers only allow push on https (or localhost). VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= +# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https. +VAPID_SUBJECT= # Structured logs to stdout (json in production, pretty in development unless overridden). LOG_LEVEL=info LOG_FORMAT= diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 647a5e6..fd2f524 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { rm } from "node:fs/promises"; import { ORPCError, onError } from "@orpc/server"; import { RPCHandler } from "@orpc/server/fetch"; import type { @@ -38,13 +37,14 @@ import { destroyBot, EmailEmulator, EncryptedSecretStore, - ExpoPushProvider, + ExpoPushSender, GraphileJobPublisher, GraphileJobWorkerHost, InMemoryJobQueue, InMemoryRealtimeFanout, InstalledConnectorProvider, IntegrationProviderSettings, + importLegacyPushTokens, isComposioEnabled, isMessagingSurfaceEnabled, isPipedreamEnabled, @@ -58,9 +58,9 @@ import { PiOAuthLogins, PipedreamConnector, PostgresRealtimeFanout, + PushNotificationProvider, pipedreamConfigFromEnv, piSessionsRoot, - pushTokenPath, reconcileCloudAgents, reconcileComputerUpdates, reconcileDelegatedTasks, @@ -69,6 +69,9 @@ import { SmtpEmailProvider, SpaceMemoryProviderResolver, toTeamChatInbound, + VapidKeys, + vapidEnvFromProcess, + WebPushSender, } from "@rakazo/adapters"; import { blockedAuthPaths, createAuth } from "@rakazo/auth"; import { signupPolicyFromEnv } from "@rakazo/core"; @@ -316,7 +319,14 @@ export async function createApp( : new PiAgentRuntime({ sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined, }); - const notifications = new ExpoPushProvider(env.dataDir); + const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env)); + const notifications = new PushNotificationProvider(prisma, { + webpush: new WebPushSender(vapid), + expo: new ExpoPushSender(), + }); + void importLegacyPushTokens(prisma, env.dataDir).catch((error) => + getLogger().error("legacy push token import failed", error), + ); const auth = createAuth(prisma, { secret: env.authSecret, baseURL: env.authUrl, @@ -372,7 +382,6 @@ export async function createApp( ), ); await removePiUserSessions(env.dataDir, userId); - await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined); }, }); // One provider instance so emulator launches and polls share the same Map. @@ -483,6 +492,7 @@ export async function createApp( secrets, oauthLogins, integrationSettings, + vapid, mcpOAuth, composio: stack.composio, connectors: stack.connector, @@ -580,7 +590,7 @@ export async function createApp( } const { matched, response } = await rpc.handle(c.req.raw, { prefix: "/rpc", - context: { actor, signal: c.req.raw.signal }, + context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") }, }); if (matched) return c.newResponse(response.body, response); await next(); diff --git a/apps/api/src/coach.ts b/apps/api/src/coach.ts new file mode 100644 index 0000000..2bf3fcc --- /dev/null +++ b/apps/api/src/coach.ts @@ -0,0 +1,96 @@ +import { ORPCError } from "@orpc/server"; +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { getCoachRhythm, setCoachRhythm } from "@rakazo/adapters"; +import type { + Actor, + CheckIn, + CoachRhythm, + CoachToday, + Goal, + Habit, + LogCheckIn, +} from "@rakazo/contracts"; +import type { CheckInFilter, GoalPatch, HabitPatch, PrismaClient } from "@rakazo/db"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + listCheckIns, + listGoals, + listHabits, + loadCoachToday, + logCheckIn, + removeGoal, + removeHabit, + updateGoal, + updateHabit, +} from "@rakazo/db"; + +export interface CoachDeps { + prisma: PrismaClient; + jobs: JobPublisher; +} + +/** Another user's ids read as missing, so a caller cannot probe them. */ +async function mapErrors(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + if (error instanceof CoachNotFoundError) { + throw new ORPCError("NOT_FOUND", { message: error.message }); + } + if (error instanceof InvalidCoachInputError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + } +} + +/** Coach procedures for the signed-in user only; nothing here is space-scoped. */ +export function createCoachService(deps: CoachDeps) { + const { prisma } = deps; + return { + today: (actor: Actor): Promise => loadCoachToday(prisma, actor.userId), + + listGoals: (actor: Actor, input: { status?: Goal["status"] }) => + listGoals(prisma, actor.userId, input), + createGoal: ( + actor: Actor, + input: { + title: string; + detail?: string; + targetDate?: string | null; + spaceId?: string | null; + }, + ) => mapErrors(() => createGoal(prisma, actor.userId, { ...input, source: "user" })), + updateGoal: (actor: Actor, { goalId, ...patch }: GoalPatch & { goalId: string }) => + mapErrors(() => updateGoal(prisma, actor.userId, goalId, patch)), + removeGoal: async (actor: Actor, input: { goalId: string }) => { + await mapErrors(() => removeGoal(prisma, actor.userId, input.goalId)); + return { ok: true as const }; + }, + + listHabits: (actor: Actor, input: { includeInactive?: boolean }): Promise => + listHabits(prisma, actor.userId, input), + createHabit: ( + actor: Actor, + input: { title: string; cadence: Habit["cadence"]; goalId?: string | null }, + ) => mapErrors(() => createHabit(prisma, actor.userId, { ...input, source: "user" })), + updateHabit: (actor: Actor, { habitId, ...patch }: HabitPatch & { habitId: string }) => + mapErrors(() => updateHabit(prisma, actor.userId, habitId, patch)), + removeHabit: async (actor: Actor, input: { habitId: string }) => { + await mapErrors(() => removeHabit(prisma, actor.userId, input.habitId)); + return { ok: true as const }; + }, + + logCheckIn: (actor: Actor, input: LogCheckIn): Promise => + mapErrors(() => logCheckIn(prisma, actor.userId, input)), + listCheckIns: (actor: Actor, input: CheckInFilter) => + mapErrors(() => listCheckIns(prisma, actor.userId, input)), + + getRhythm: (actor: Actor): Promise => getCoachRhythm(prisma, actor.userId), + setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise => + setCoachRhythm(deps, { userId: actor.userId, rhythm }), + }; +} diff --git a/apps/api/src/connected-app-facts.ts b/apps/api/src/connected-app-facts.ts new file mode 100644 index 0000000..5b83abe --- /dev/null +++ b/apps/api/src/connected-app-facts.ts @@ -0,0 +1,50 @@ +import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core"; +import type { PrismaClient } from "@rakazo/db"; +import { updateUserProfile } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; + +/** + * The app's own name: a featured app's label, otherwise the label of the user's first account for + * it (later accounts may be named "Gmail 2" or renamed). + */ +async function connectedAppName( + prisma: PrismaClient, + connection: { userId: string; connectorId: string; provider: string }, +): Promise { + const featured = matchFeaturedConnectorId(connection.provider); + if (featured) return FEATURED_CONNECTOR_LABELS[featured]; + const first = await prisma.connection.findFirst({ + where: { + userId: connection.userId, + connectorId: connection.connectorId, + provider: connection.provider, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { displayName: true }, + }); + const name = first?.displayName.trim().slice(0, 80); + return name || null; +} + +/** + * Remembers "Uses " in the user's profile when one of their connections becomes connected. + * Revoking the connection keeps the fact; the user removes it in About you. Best effort: the + * connection itself has already succeeded, so a full profile or a database error only logs. + */ +export async function recordConnectedApp( + prisma: PrismaClient, + connection: { userId: string; connectorId: string; provider: string }, +): Promise { + try { + const name = await connectedAppName(prisma, connection); + if (!name) return; + await updateUserProfile(prisma, connection.userId, { + addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }], + }); + } catch (error) { + getLogger().error("connected app fact was not recorded", error, { + connectorId: connection.connectorId, + provider: connection.provider, + }); + } +} diff --git a/apps/api/src/inbox.test.ts b/apps/api/src/inbox.test.ts index 08360a1..5da36aa 100644 --- a/apps/api/src/inbox.test.ts +++ b/apps/api/src/inbox.test.ts @@ -18,9 +18,11 @@ function run( status: "waiting_input" | "waiting_takeover", thread: Partial = {}, bot = scout, + spaceId = "space-home", ): InboxRunRow { return { id, + spaceId, status, updatedAt: at(0), bot, @@ -48,8 +50,10 @@ const approval = { function unreadThread( bot: InboxUnreadThreadRow["bot"], latest?: { id: string; text: string; minute: number }, + spaceId = "space-home", ): InboxUnreadThreadRow { return { + spaceId, createdAt: at(0), bot, messages: latest @@ -227,4 +231,64 @@ describe("buildInboxItems", () => { }); expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() }); }); + + it("lists asks from another space's main conversation, but not the open space's own", () => { + const project = { id: "space-launch", name: "Launch" }; + const main = (groupId: string) => ({ + botId: null, + groupId, + group: { kind: "main", archivedAt: null }, + }); + const items = buildInboxItems({ + runs: [ + run("home-main", "waiting_input", main("group-main-home")), + run("project-main", "waiting_input", main("group-main-launch"), writer, project.id), + ], + messages: [ + message("ask-home", "home-main", [question], 1), + message("ask-project", "project-main", [question], 2), + ], + unreadThreads: [], + otherSpaces: new Map([[project.id, project]]), + }); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + runId: "project-main", + space: project, + target: { groupId: "group-main-launch" }, + }); + }); + + it("names the space of items from the user's other spaces", () => { + const side = { id: "space-side", name: "Side project" }; + const items = buildInboxItems({ + runs: [ + run("run-home", "waiting_input"), + run("run-side", "waiting_input", {}, writer, side.id), + run("takeover-side", "waiting_takeover", {}, writer, side.id), + ], + messages: [ + message("ask-home", "run-home", [question], 1), + message("ask-side", "run-side", [question], 2), + ], + unreadThreads: [ + unreadThread( + { id: "bot-quiet", name: "Quiet", color: "#3EC5A8" }, + { id: "quiet-latest", text: "Done", minute: 3 }, + side.id, + ), + ], + otherSpaces: new Map([[side.id, side]]), + }); + + expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([ + ["takeover", "Side project"], + ["ask", null], + ["ask", "Side project"], + ["unread", "Side project"], + ]); + expect(items[1]).not.toHaveProperty("space"); + expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } }); + }); }); diff --git a/apps/api/src/inbox.ts b/apps/api/src/inbox.ts index 549d5a6..b430f85 100644 --- a/apps/api/src/inbox.ts +++ b/apps/api/src/inbox.ts @@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { promptSnippet } from "./runs.js"; -/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */ +/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */ const INBOX_RUN_LIMIT = 50; const INBOX_UNREAD_LIMIT = 50; const INBOX_PREVIEW_LENGTH = 280; @@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280; type AskBlock = Extract; type ComputerBlock = Extract; type InboxBot = { id: string; name: string; color: string }; +type InboxSpace = NonNullable; export interface InboxRunRow { id: string; + spaceId: string; status: string; updatedAt: Date; bot: InboxBot; @@ -33,6 +35,7 @@ export interface InboxMessageRow { } export interface InboxUnreadThreadRow { + spaceId: string; createdAt: Date; bot: InboxBot | null; /** The newest bot message, if any. */ @@ -73,12 +76,14 @@ function previewText(blocks: unknown): string { } /** - * Where an ask can be answered from. The main conversation already shows its own asks, and an - * archived group or an external conversation has no thread target to answer through. + * Where an ask can be answered from. The open space's main conversation already shows its own + * asks (a project's main conversation does not), and an archived group or an external + * conversation has no thread target to answer through. */ -function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null { +function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null { if (thread.groupId) { - if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null; + if (!thread.group || thread.group.archivedAt) return null; + if (thread.group.kind === "main" && currentSpace) return null; return { groupId: thread.groupId }; } return thread.botId ? { botId: thread.botId } : null; @@ -95,14 +100,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number { /** * Turns waiting runs, their ask and takeover messages, and unread threads into inbox items: * everything that waits on the user first, then unread threads not already covered, each - * oldest first. + * oldest first. Items from another space name it. */ export function buildInboxItems(input: { runs: InboxRunRow[]; /** Ask and takeover messages of those runs, newest first. */ messages: InboxMessageRow[]; unreadThreads: InboxUnreadThreadRow[]; + /** The user's other spaces by id; rows of any space not listed belong to the current one. */ + otherSpaces?: ReadonlyMap; }): InboxItem[] { + const spaceOf = (spaceId: string) => { + const space = input.otherSpaces?.get(spaceId); + return space ? { space } : {}; + }; const messagesByRun = new Map(); for (const message of input.messages) { if (!message.runId) continue; @@ -115,13 +126,14 @@ export function buildInboxItems(input: { for (const run of input.runs) { const messages = messagesByRun.get(run.id) ?? []; if (run.status === "waiting_input") { - const target = askTarget(run.thread); + const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId)); const pending = target ? newest(messages, pendingAsk) : undefined; if (!target || !pending) continue; waiting.push({ kind: isApprovalAskBlock(pending.block) ? "approval" : "ask", bot: run.bot, target, + ...spaceOf(run.spaceId), runId: run.id, messageId: pending.message.id, ask: pending.block, @@ -135,6 +147,7 @@ export function buildInboxItems(input: { kind: "takeover", bot: run.bot, target: { botId: run.bot.id }, + ...spaceOf(run.spaceId), runId: run.id, messageId: request?.message.id ?? null, text: request?.block.text ?? "", @@ -154,6 +167,7 @@ export function buildInboxItems(input: { kind: "unread", bot: thread.bot, target, + ...spaceOf(thread.spaceId), messageId: latest?.id ?? null, text: previewText(latest?.blocks), createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(), @@ -165,13 +179,34 @@ export function buildInboxItems(input: { const botSelect = { id: true, name: true, color: true } as const; -/** The current space's inbox: only the actor's own bots, runs and threads. */ +/** + * The other spaces whose items the inbox shows: every space the user belongs to, except spaces + * being deleted. + */ +async function otherInboxSpaces( + prisma: PrismaClient, + actor: Actor, +): Promise> { + const memberships = await prisma.spaceMember.findMany({ + where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } }, + select: { space: { select: { id: true, name: true } } }, + }); + return new Map(memberships.map(({ space }) => [space.id, space])); +} + +/** + * The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are + * answered and opened in their own space, where the thread procedures check membership and + * ownership again. + */ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise { + const otherSpaces = await otherInboxSpaces(prisma, actor); + const spaceIds = [actor.spaceId, ...otherSpaces.keys()]; const ownBot = { userId: actor.userId, archivedAt: null }; const [runs, unreadThreads] = await Promise.all([ prisma.run.findMany({ where: { - spaceId: actor.spaceId, + spaceId: { in: spaceIds }, userId: actor.userId, status: { in: ["waiting_input", "waiting_takeover"] }, bot: ownBot, @@ -179,6 +214,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise text.replace(/\s/g, " "); +const off = { morning: null, evening: null, weekly: null }; + +describe("onboarding steps", () => { + it("asks in order and offers notifications only to a device that can still turn push on", () => { + expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal"); + expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm"); + expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours"); + expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications"); + expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull(); + expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull(); + }); + + it("gives every onboarding card a quiet way out, and another bot's focus card none", () => { + const context = intakeContext(null, new Date()); + for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) { + const card = intakeStep(id).card(context); + expect(card.step).toBe(id); + expect(secondaryOption(card)).toBeDefined(); + } + expect(focusCard().step).toBeUndefined(); + expect(secondaryOption(focusCard())).toBeUndefined(); + }); + + it("writes quiet hours in the user's locale", () => { + const labels = (locale: string | null) => + intakeStep("quiet_hours") + .card(intakeContext({ locale, timeZone: null }, new Date())) + .options.map((option) => plain(option.label)); + expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]); + expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]); + expect(labels("not a tag")).toEqual(labels("en")); + }); +}); + +describe("readIntakeAnswer", () => { + const goal = intakeStep("goal").card(intakeContext(null, new Date())); + const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date())); + + it("accepts the card's own options and words only where the card takes them", () => { + expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({ + option: { id: "weekly", letter: "C", label: "Once a week" }, + }); + expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull(); + expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull(); + expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" }); + expect(readIntakeAnswer(goal, "_text", " ")).toBeNull(); + expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip"); + }); +}); + +describe("closingNote", () => { + const vienna = (iso: string, locale = "de-AT") => + intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso)); + const note = (rhythm: Parameters[0], context: ReturnType) => { + const { nextCheckInAt, text } = closingNote(rhythm, context); + return { nextCheckInAt, text: plain(text) }; + }; + + it("names the first check-in in the user's time zone", () => { + const morning = { ...off, morning: "08:00" }; + expect(note(morning, vienna("2026-10-07T04:00:00Z"))).toEqual({ + nextCheckInAt: "2026-10-07T06:00:00.000Z", + text: "All set. I’ll check in today at 8:00.", + }); + expect(note(morning, vienna("2026-10-07T09:00:00Z"))).toEqual({ + nextCheckInAt: "2026-10-08T06:00:00.000Z", + text: "All set. I’ll check in tomorrow at 8:00.", + }); + expect(note({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z")).text).toBe( + "All set. I’ll check in today at 18:00.", + ); + // Wednesday in Vienna; the weekly review is on Sunday. + const weekly = { ...off, weekly: { day: 0, time: "18:00" } }; + expect(note(weekly, vienna("2026-10-07T09:00:00Z", "en-US"))).toEqual({ + nextCheckInAt: "2026-10-11T16:00:00.000Z", + text: "All set. I’ll check in on Sunday at 6:00 PM.", + }); + }); + + it("says the user can just write when no check-in is set", () => { + expect(closingNote(off, vienna("2026-10-07T09:00:00Z"))).toEqual({ + kind: "intake_note", + note: "done", + nextCheckInAt: null, + text: "All set. I’m here whenever you need me.", + }); + }); + + it("falls back to English and UTC for an unusable profile", () => { + expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({ + locale: "en", + timeZone: "UTC", + }); + }); +}); diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts new file mode 100644 index 0000000..1ed62a6 --- /dev/null +++ b/apps/api/src/onboarding-intake.ts @@ -0,0 +1,297 @@ +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters"; +import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; +import { knownTimeZone, nextCronDateAcross } from "@rakazo/core"; +import type { Prisma, PrismaClient } from "@rakazo/db"; +import { createGoal, updateNotificationSettings, updateUserProfile } from "@rakazo/db"; + +/** + * The questions the main bot asks a new user in the main conversation, one card at a time. + * Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order + * the user sees. Everything here is deterministic, so no model tokens are spent. + */ + +export type ChoiceBlock = Extract; +type ChoiceOption = ChoiceBlock["options"][number]; + +/** A validated answer: an option of the card, or the user's own words. */ +export type IntakeAnswer = + | { option: ChoiceOption; text?: undefined } + | { option?: undefined; text: string }; + +/** What a card may adapt to: times are written in the user's locale. */ +export interface IntakeContext { + locale: string; + timeZone: string; + now: Date; +} + +export interface IntakeEffects { + prisma: PrismaClient; + jobs: JobPublisher; + tx: Prisma.TransactionClient; + userId: string; +} + +interface IntakeStep { + id: OnboardingStep; + /** Asked only when this holds; otherwise onboarding moves past the step. */ + asks?: (device: { canEnablePush: boolean }) => boolean; + card: (context: IntakeContext) => ChoiceBlock; + /** Records a validated answer. Runs while the main conversation is locked. */ + save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise; +} + +export interface FocusOption { + id: string; + letter: string; + label: string; + apps: string[]; + /** Stored as a `goals` fact, so it stays a plain English statement. */ + fact: string; +} + +export const FOCUS_OPTIONS: FocusOption[] = [ + { + id: "day", + letter: "A", + label: "Day-to-day work", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants help with day-to-day work first", + }, + { + id: "inbox", + letter: "B", + label: "Inbox & email", + apps: ["gmail", "googlecalendar", "slack"], + fact: "Wants help with inbox and email first", + }, + { + id: "research", + letter: "C", + label: "Research & writing", + apps: ["hackernews", "notion", "googledocs"], + fact: "Wants help with research and writing first", + }, + { + id: "crew", + letter: "D", + label: "Specialist crew", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants a coordinated crew of specialist bots", + }, +]; + +const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" }; + +/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */ +export function focusCard(step?: "focus"): ChoiceBlock { + return { + kind: "choice", + question: "What do you want me on first?", + options: [ + ...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })), + ...(step ? [SKIP] : []), + ], + ...(step ? { step } : {}), + }; +} + +const RHYTHMS: Record = { + morning: { morning: "08:00", evening: null, weekly: null }, + morning_evening: { morning: "08:00", evening: "18:00", weekly: null }, + weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } }, + none: { morning: null, evening: null, weekly: null }, +}; + +const QUIET_HOURS: Record = { + "22-07": { start: "22:00", end: "07:00" }, + "23-08": { start: "23:00", end: "08:00" }, +}; + +export const INTAKE_STEPS: IntakeStep[] = [ + { + id: "focus", + card: () => focusCard("focus"), + save: async ({ prisma, tx, userId }, answer) => { + const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id); + if (!option) return; + await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } }); + await updateUserProfile(prisma, userId, { + addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }], + }); + }, + }, + { + id: "goal", + card: () => ({ + kind: "choice", + step: "goal", + question: "What’s one thing you want to get done in the next few weeks?", + textInput: { placeholder: "Your goal" }, + options: [SKIP], + }), + save: async ({ prisma, userId }, answer) => { + if (answer.text) + await createGoal(prisma, userId, { title: answer.text, source: "onboarding" }); + }, + }, + { + id: "rhythm", + card: () => ({ + kind: "choice", + step: "rhythm", + question: "When should I check in with you?", + options: [ + { id: "morning", letter: "A", label: "Every morning" }, + { id: "morning_evening", letter: "B", label: "Morning and evening" }, + { id: "weekly", letter: "C", label: "Once a week" }, + { id: "none", letter: "", label: "Not now" }, + ], + }), + save: async ({ prisma, jobs, userId }, answer) => { + const rhythm = answer.option && RHYTHMS[answer.option.id]; + if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm }); + }, + }, + { + id: "quiet_hours", + card: (context) => ({ + kind: "choice", + step: "quiet_hours", + question: "When should I stay quiet?", + options: [ + ...Object.entries(QUIET_HOURS).map(([id, range], index) => ({ + id, + letter: String.fromCharCode(65 + index), + label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`, + })), + SKIP, + ], + }), + save: async ({ prisma, userId }, answer) => { + const quietHours = answer.option && QUIET_HOURS[answer.option.id]; + if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours }); + }, + }, + { + // The browser subscribes when the user taps "Turn on"; the answer only moves on. + id: "notifications", + asks: ({ canEnablePush }) => canEnablePush, + card: () => ({ + kind: "choice", + step: "notifications", + question: "Can I send you notifications?", + options: [ + { id: "on", letter: "A", label: "Turn on" }, + { id: "later", letter: "", label: "Not now" }, + ], + }), + save: async () => {}, + }, +]; + +export function intakeStep(id: OnboardingStep): IntakeStep { + const step = INTAKE_STEPS.find((entry) => entry.id === id); + if (!step) throw new Error(`Unknown onboarding step: ${id}`); + return step; +} + +/** The step after `id` that applies to this device, or null when onboarding is done. */ +export function nextIntakeStep( + id: OnboardingStep, + device: { canEnablePush: boolean }, +): IntakeStep | null { + const index = INTAKE_STEPS.findIndex((entry) => entry.id === id); + return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null; +} + +/** Checks an answer against the card it answers; null when the card does not offer it. */ +export function readIntakeAnswer( + card: ChoiceBlock, + optionId: string, + text: string | undefined, +): IntakeAnswer | null { + if (optionId === CHOICE_TEXT_ANSWER_ID) { + const words = text?.trim(); + return card.textInput && words ? { text: words } : null; + } + const option = card.options.find((entry) => entry.id === optionId); + return option ? { option } : null; +} + +/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */ +export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined { + return card.options.find((option) => !option.letter); +} + +/** Card times and the closing line follow the user's profile, falling back to English and UTC. */ +export function intakeContext( + profile: { locale: string | null; timeZone: string | null } | null, + now: Date, +): IntakeContext { + let locale = "en"; + try { + if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en"; + } catch { + // Not a language tag; keep English. + } + return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now }; +} + +function formatClock(time: string, locale: string): string { + const [hour = 0, minute = 0] = time.split(":").map(Number); + return new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + }).format(new Date(Date.UTC(2000, 0, 1, hour, minute))); +} + +function localDay(date: Date, timeZone: string): number { + const [year, month, day] = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .format(date) + .split("-") + .map(Number); + return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000; +} + +export type IntakeNote = Extract; + +/** + * The closing note: what happens next, i.e. the first check-in when there is one. The web says + * it in the user's language from `nextCheckInAt`; `text` is the English line for everything else. + */ +export function closingNote(rhythm: CoachRhythm, context: IntakeContext): IntakeNote { + const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []); + const next = nextCronDateAcross(crons, context.now, context.timeZone); + return { + kind: "intake_note", + note: "done", + nextCheckInAt: next?.toISOString() ?? null, + text: closingText(next, context), + }; +} + +function closingText(next: Date | null, context: IntakeContext): string { + if (!next) return "All set. I’m here whenever you need me."; + const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone); + const day = + days === 0 + ? "today" + : days === 1 + ? "tomorrow" + : `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`; + const time = new Intl.DateTimeFormat(context.locale, { + hour: "numeric", + minute: "2-digit", + timeZone: context.timeZone, + }).format(next); + return `All set. I’ll check in ${day} at ${time}.`; +} diff --git a/apps/api/src/onboarding-scan.test.ts b/apps/api/src/onboarding-scan.test.ts index 549a45b..9cbafd9 100644 --- a/apps/api/src/onboarding-scan.test.ts +++ b/apps/api/src/onboarding-scan.test.ts @@ -1,166 +1,192 @@ -import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type * as db from "@rakazo/db"; -import { SpaceLimitError } from "@rakazo/db"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; - -const createSpaceForMember = vi.hoisted(() => vi.fn()); -const ensureMainConversation = vi.hoisted(() => vi.fn()); -vi.mock("@rakazo/db", async (original) => ({ - ...(await original()), - createSpaceForMember, - ensureMainConversation, -})); +import path from "node:path"; +import type { PrismaClient } from "@rakazo/db"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { + applyOnboardingScan, + discoverOnboardingScan, + onboardingScanFolder, + onboardingScanStatus, + startOnboardingScan, +} from "./onboarding-scan.js"; const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true }; const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false }; +const roots: string[] = []; -type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } }; - -/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */ -function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) { - const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = []; - createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => { - if (spaces.length >= spaceLimit) throw new SpaceLimitError(); - const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null }; - spaces.push(space); - return { id: space.id, name: space.name }; - }); - const prisma = { - space: { - findMany: async () => spaces.filter((space) => space.workspacePath), - update: async ({ where, data }: SpaceUpdate) => { - const space = spaces.find((candidate) => candidate.id === where.id); - if (space) space.workspacePath = data.workspacePath; - }, +/** Fails the test if the scan writes anything before its checks pass. */ +const untouchedPrisma = new Proxy( + {}, + { + get() { + throw new Error("the database must not be touched"); }, - } as unknown as db.PrismaClient; - return { prisma, spaces }; + }, +) as PrismaClient; + +async function tempDir(): Promise { + const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-"))); + roots.push(dir); + return dir; } -async function scanHomeWithOneFile(): Promise { - const home = await mkdtemp(join(tmpdir(), "evoo-scan-")); - await mkdir(join(home, "Projects")); - await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n"); - return home; +async function files(root: string, entries: string[]): Promise { + for (const entry of entries) { + const file = path.join(root, entry); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, "x\n"); + } } -async function scanHomeWithRepos(...repos: string[]): Promise { - const home = await scanHomeWithOneFile(); - for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true }); - return home; +async function project(root: string, relative: string, entries: string[]): Promise { + await mkdir(path.join(root, relative, ".git"), { recursive: true }); + await files(path.join(root, relative), entries); } async function completed(scanId: string, userId: string) { return await vi.waitFor( - async () => { - const status = await getScanStatus(scanId, userId); - if (status.status !== "complete" && status.status !== "failed") throw new Error("pending"); + () => { + const status = onboardingScanStatus(scanId, userId); + if (status.status === "scanning") throw new Error("pending"); return status; }, - { timeout: 10_000, interval: 50 }, + { timeout: 10_000, interval: 20 }, ); } -/** The emulated runtime that the shared test setup pins keeps the scan simulated. */ -function allowHostScan() { - vi.stubEnv("AGENT_RUNTIME", ""); - vi.stubEnv("SANDBOX_PROVIDER", ""); -} +afterAll(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +}); describe("onboarding scan", () => { - afterEach(() => { - vi.unstubAllEnvs(); - createSpaceForMember.mockReset(); - ensureMainConversation.mockReset(); + it("lists git projects with their languages and tools and never follows a symlink", async () => { + const home = await tempDir(); + const outside = await tempDir(); + // A home folder tracked by git is not itself a project. + await mkdir(path.join(home, ".git")); + await project(home, "code/atlas", [ + "src/a.ts", + "src/b.ts", + "src/c.ts", + "src/d.tsx", + "scripts/build.js", + "Dockerfile", + ".github/workflows/ci.yml", + "infra/main.tf", + "src/odd.constructor", + "node_modules/dep/index.js", + "node_modules/dep/more.js", + ]); + await project(home, "code/garden", ["main.py"]); + await project(home, "deep/a/b/edge", ["tool.py"]); + await project(home, "deep/a/b/c/too-deep", ["main.go"]); + await project(home, ".hidden/secret", ["main.go"]); + await project(home, "notes/node_modules/vendored", ["main.go"]); + await files(home, ["notes/todo.md"]); + await project(outside, "escaped", ["main.rs"]); + await symlink(path.join(outside, "escaped"), path.join(home, "linked")); + await symlink(outside, path.join(home, "code/atlas/src/outside")); + + const findings = await discoverOnboardingScan(home); + + expect(findings.projects).toEqual([ + { + id: "project-0", + name: "atlas", + path: "code/atlas", + languages: ["TypeScript", "JavaScript"], + tools: ["Docker", "GitHub Actions", "Terraform"], + }, + { id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] }, + { id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] }, + ]); + expect(findings.facts).toEqual([ + { id: "language:Python", label: "Python", category: "work", value: "Codes in Python" }, + { + id: "language:JavaScript", + label: "JavaScript", + category: "work", + value: "Codes in JavaScript", + }, + { + id: "language:TypeScript", + label: "TypeScript", + category: "work", + value: "Codes in TypeScript", + }, + { id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" }, + { + id: "tool:GitHub Actions", + label: "GitHub Actions", + category: "tools", + value: "Uses GitHub Actions", + }, + { id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" }, + ]); }); - it("hides a scan from every account except the one that started it", async () => { - const { prisma } = fakeDatabase(); - const { scanId } = await startOnboardingScan({ actor: member }, prisma); - await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/); - await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({ - status: expect.any(String), - }); - await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/); + it("offers a scan folder only to the deployment owner and only when it exists", async () => { + const home = await tempDir(); + await files(home, ["file.txt"]); + await expect(onboardingScanFolder(member, home)).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, home)).resolves.toBe(home); + await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" }); }); - it("never reads the host without a configured scan home", async () => { - const { prisma } = fakeDatabase(); - const { scanId } = await startOnboardingScan({ actor: owner }, prisma); + it("keeps a scan with the account that started it and returns only relative paths", async () => { + const home = await tempDir(); + await project(home, "code/atlas", ["index.ts"]); + const { scanId } = await startOnboardingScan(owner, home); + const status = await completed(scanId, owner.userId); - expect(status.status).toBe("complete"); - expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } }); - }); - - it("lets only the deployment owner discover the configured scan home", async () => { - allowHostScan(); - const scanHome = await scanHomeWithRepos("Projects/app"); - const { prisma } = fakeDatabase(); - - const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma); - const memberStatus = await completed(asMember.scanId, member.userId); - expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } }); - expect(createSpaceForMember).not.toHaveBeenCalled(); - - const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const ownerStatus = await completed(asOwner.scanId, owner.userId); - expect(ownerStatus.status).toBe("complete"); - expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } }); - }); - - it("links each repository of the deployment owner to one project of its own", async () => { - allowHostScan(); - const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim"); - const app = await realpath(join(scanHome, "Projects", "app")); - const { prisma, spaces } = fakeDatabase(); - - const first = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const firstStatus = await completed(first.scanId, owner.userId); - expect(firstStatus.status).toBe("complete"); - expect(firstStatus.result).toMatchObject({ - localRepos: [{ name: "app" }], - createdProjects: [{ spaceId: "space-1", name: "app", path: app }], + expect(status).toEqual({ + status: "complete", + projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }], + facts: [{ id: "language:TypeScript", label: "TypeScript" }], }); - expect(createSpaceForMember).toHaveBeenCalledWith(prisma, { - currentSpaceId: owner.spaceId, - userId: owner.userId, - name: "app", + expect(JSON.stringify(status)).not.toContain(home); + expect(() => onboardingScanStatus(scanId, member.userId)).toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + await expect( + applyOnboardingScan(untouchedPrisma, member, home, { + scanId, + projectIds: ["project-0"], + factIds: [], + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("checks ownership, the folder and every chosen project again before writing", async () => { + const home = await tempDir(); + const outside = await tempDir(); + await project(home, "code/atlas", ["index.ts"]); + await project(home, "code/garden", ["main.py"]); + await project(outside, "elsewhere", ["main.rs"]); + const { scanId } = await startOnboardingScan(owner, home); + await completed(scanId, owner.userId); + const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] }; + + await expect( + applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + applyOnboardingScan(untouchedPrisma, owner, outside, choice), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + // The chosen project became a symlink out of the scan folder after the scan. + await rm(path.join(home, "code/garden"), { recursive: true }); + await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden")); + await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "That folder does not exist or is not readable.", }); - expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]); - // Each new project starts with its own main agent. - expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1"); - - // Scanning again finds the linked project instead of creating a second one. - const again = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const againStatus = await completed(again.scanId, owner.userId); - expect(againStatus.result).toMatchObject({ createdProjects: [] }); - expect(createSpaceForMember).toHaveBeenCalledTimes(1); - }); - - it("joins a running scan instead of linking its repositories twice", async () => { - allowHostScan(); - const scanHome = await scanHomeWithRepos("Projects/app"); - const { prisma, spaces } = fakeDatabase(); - - const first = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const second = await startOnboardingScan({ actor: owner, scanHome }, prisma); - expect(second.scanId).toBe(first.scanId); - await completed(first.scanId, owner.userId); - expect(spaces).toHaveLength(1); - }); - - it("stops creating projects at the space limit and still completes", async () => { - allowHostScan(); - const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c"); - const { prisma } = fakeDatabase({ spaceLimit: 1 }); - - const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const status = await completed(scanId, owner.userId); - expect(status.status).toBe("complete"); - expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] }); - expect(createSpaceForMember).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/api/src/onboarding-scan.ts b/apps/api/src/onboarding-scan.ts index b804fe7..2b22819 100644 --- a/apps/api/src/onboarding-scan.ts +++ b/apps/api/src/onboarding-scan.ts @@ -1,840 +1,434 @@ -// Onboarding scan — real filesystem scan, Git analysis, and profile compilation. -// Runs synchronously on the host machine (no sandbox needed). -import { spawn } from "node:child_process"; -import { access, readdir, stat } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; -import type { Actor } from "@rakazo/contracts"; +// Onboarding discovery. With the deployment owner's consent, it lists the code projects (git +// checkouts) in the configured scan folder and the languages and tools they use. It reads +// directory listings only, never file contents, and never follows a symlink, so nothing outside +// the folder is reached. Results stay with the account that started the scan, and nothing is +// created until the owner picks projects and facts from them. +import { randomUUID } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { ORPCError } from "@orpc/server"; +import type { + Actor, + OnboardingScanApplyInput, + OnboardingScanApplyResult, + OnboardingScanStatus, + UserFactCategory, +} from "@rakazo/contracts"; +import { ONBOARDING_SCAN_MAX_FACTS, ONBOARDING_SCAN_MAX_PROJECTS } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; import { createSpaceForMember, ensureMainConversation, + InvalidProjectWorkspacePathError, InvalidSpaceNameError, + InvalidUserFactError, resolveProjectWorkspaceDirectory, SpaceLimitError, + updateUserProfile, wireProjectWorkspace, } from "@rakazo/db"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- +/** Project roots are looked for at most this deep below the scan folder. */ +const PROJECT_DEPTH = 4; +/** Bounds the walk of a large home folder. */ +const MAX_DIRECTORIES = 5_000; +/** Bounds the language count inside one project. */ +const MAX_FILES_PER_PROJECT = 5_000; +const MAX_FILE_DEPTH = 8; +const MAX_LANGUAGES_PER_PROJECT = 3; +const SCAN_TTL_MS = 60 * 60_000; -interface ScanResult { - localRepos: RepoInfo[]; - projectFolders: ProjectFolder[]; - fileSummary: FileSummary; - profileSummary: ProfileSummary; - agentBriefing: string; - createdProjects: CreatedProject[]; - subAgentRoles: SubAgentRole[]; -} +const SKIPPED_DIRECTORIES = new Set([ + "node_modules", + "dist", + "build", + "out", + "target", + "vendor", + "venv", + "__pycache__", + "Library", +]); -interface RepoInfo { - path: string; - name: string; - remote: string | null; - branch: string; - lastCommit: string | null; - languages: Record; - sizeBytes: number; -} +const LANGUAGES = new Map( + Object.entries({ + ts: "TypeScript", + tsx: "TypeScript", + mts: "TypeScript", + cts: "TypeScript", + js: "JavaScript", + jsx: "JavaScript", + mjs: "JavaScript", + cjs: "JavaScript", + py: "Python", + go: "Go", + rs: "Rust", + java: "Java", + kt: "Kotlin", + kts: "Kotlin", + swift: "Swift", + rb: "Ruby", + php: "PHP", + c: "C", + h: "C", + cc: "C++", + cpp: "C++", + cxx: "C++", + hpp: "C++", + cs: "C#", + dart: "Dart", + ex: "Elixir", + exs: "Elixir", + scala: "Scala", + lua: "Lua", + }), +); -interface ProjectFolder { - path: string; - name: string; - type: "git" | "workspace" | "documents" | "media" | "music" | "code"; - fileCount: number; - modifiedAt: string; -} +const COMPOSE_FILES = new Set([ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +]); -interface FileSummary { - totalFiles: number; - totalSizeBytes: number; - byType: Record; - byExtension: Record; -} - -interface ProfileSummary { - interests: string[]; - skills: string[]; - workStyle: string; - communication: string; - languages: string[]; - techStack: string[]; -} - -interface CreatedProject { - spaceId: string; - name: string; - path: string; -} - -interface ScanRecord { +interface FoundProject { + id: string; + name: string; + /** Relative to the scan folder. */ + path: string; + languages: string[]; + tools: string[]; +} + +interface FoundFact { + id: string; + label: string; + category: UserFactCategory; + /** Stored as data for the user's bots, so it stays a plain English statement. */ + value: string; +} + +export interface OnboardingScanFindings { + projects: FoundProject[]; + facts: FoundFact[]; +} + +interface ScanRecord extends OnboardingScanFindings { id: string; - botId: string; ownerUserId: string; - status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed"; - progress: number; - message: string; - result: string; - startedAt: Date; - completedAt: Date | null; + /** Real path of the scanned folder. */ + home: string; + status: "scanning" | "complete" | "failed"; + finishedAt: number | null; + applying: boolean; } -// --------------------------------------------------------------------------- -// Registry (in-memory, 1h TTL) -// --------------------------------------------------------------------------- +/** Per API process; a scan only lives as long as the onboarding that started it. */ +const scans = new Map(); -const scanRegistry = new Map(); - -const emptyScanResult = (): ScanResult => ({ - localRepos: [], - projectFolders: [], - fileSummary: { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }, - profileSummary: { - interests: [], - skills: [], - workStyle: "structured", - communication: "direct", - languages: [], - techStack: [], - }, - agentBriefing: "Profile ready", - createdProjects: [], - subAgentRoles: [], -}); - -async function runSimulatedScan(record: ScanRecord) { - const phases: Array<{ status: ScanRecord["status"]; progress: number; message: string }> = [ - { status: "scanning", progress: 25, message: "Scanning local files and repositories..." }, - { status: "connecting", progress: 40, message: "Connecting to your services..." }, - { status: "building", progress: 60, message: "Building your profile..." }, - { status: "briefing", progress: 80, message: "Briefing your agent team..." }, - ]; - for (const phase of phases) { - record.status = phase.status; - record.progress = phase.progress; - record.message = phase.message; - await new Promise((resolve) => setTimeout(resolve, 400)); - } - record.status = "complete"; - record.progress = 100; - record.message = "Scan complete — profile ready"; - record.result = JSON.stringify(emptyScanResult()); - record.completedAt = new Date(); +/** Code-point order, so results do not depend on the server's locale. */ +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; } -type ScanActor = Pick; - -export interface OnboardingScanRequest { - actor: ScanActor; - botId?: string; - /** Home-style directory to discover. Unset keeps the scan off the host filesystem. */ - scanHome?: string; +/** Most frequent first, then by name. */ +function byCount([leftName, left]: [string, number], [rightName, right]: [string, number]): number { + return right - left || compareText(leftName, rightName); } -export async function startOnboardingScan( - { actor, botId, scanHome }: OnboardingScanRequest, - prisma: PrismaClient, -): Promise<{ scanId: string }> { - // A repeated start (a remount, a retry) joins the running scan, so no repository is linked twice. - for (const running of scanRegistry.values()) { - if (running.ownerUserId === actor.userId && !running.completedAt) return { scanId: running.id }; - } - const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const record: ScanRecord = { - id: scanId, - botId: botId ?? "", - ownerUserId: actor.userId, - status: "scanning", - progress: 0, - message: "Scanning your computer...", - result: "[]", - startedAt: new Date(), - completedAt: null, - }; - scanRegistry.set(scanId, record); - - // Run async - void (async () => { - try { - // The host is the deployment owner's machine: only their onboarding may discover it, and - // only inside the directory the deployment explicitly configured. - if ( - !scanHome || - !actor.isDeploymentOwner || - process.env.AGENT_RUNTIME === "scripted" || - process.env.SANDBOX_PROVIDER === "fake" - ) { - await runSimulatedScan(record); - return; - } - const result = await executeFullScan(scanHome, actor, prisma); - record.status = "complete"; - record.progress = 100; - record.message = "Scan complete — profile ready"; - record.result = JSON.stringify(result); - record.completedAt = new Date(); - } catch (error) { - record.status = "failed"; - record.message = error instanceof Error ? error.message : "Scan failed"; - record.completedAt = new Date(); - } - })(); - - return { scanId }; -} - -export async function getScanStatus( - scanId: string, - userId: string, -): Promise<{ - status: ScanRecord["status"]; - progress: number; - message: string; - result?: unknown; -}> { - const record = scanRegistry.get(scanId); - if (!record || record.ownerUserId !== userId) throw new Error("Scan not found"); - const result = record.result ? JSON.parse(record.result) : undefined; - return { status: record.status, progress: record.progress, message: record.message, result }; -} - -// --------------------------------------------------------------------------- -// Full scan pipeline -// --------------------------------------------------------------------------- - -async function executeFullScan( - homeDir: string, - actor: ScanActor, - prisma: PrismaClient, -): Promise { - // Phase 1: Local file scan - const [fileSummary, projectFolders] = await scanFiles(homeDir); - - // Phase 2: Git repository discovery - const localRepos = await discoverGitRepos(homeDir); - - // Phase 3: External service analysis (GitHub, etc.) - // Phase 4: Profile compilation from findings - const profileSummary = compileProfile(localRepos, fileSummary, projectFolders); - - // Phase 6: Projects for discovered repositories - const createdProjects = await createProjects(localRepos, actor, prisma); - - // Phase 7: Sub-agent role creation - const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos); - - // Phase 5: Agent briefing (includes sub-agent roles) - const agentBriefing = generateBriefing(localRepos, profileSummary, fileSummary, subAgentRoles); - - return { - localRepos, - projectFolders, - fileSummary, - profileSummary, - agentBriefing, - createdProjects, - subAgentRoles, - }; -} - -// --------------------------------------------------------------------------- -// Phase 1: File scan -// --------------------------------------------------------------------------- - -async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[]]> { - const summary: FileSummary = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; - const folders: ProjectFolder[] = []; - - // Scan common directories - const scanDirs = [ - "Projects", - "Documents", - "Pictures", - "Music", - "Downloads", - "Desktop", - "src", - "code", - "work", - "data", - "repos", - "dev", - "workspace", - ]; - - for (const subDir of scanDirs) { - const dirPath = join(homeDir, subDir); - try { - await access(dirPath); - const stats = await scanDirectory(dirPath, homeDir, subDir); - summary.totalFiles += stats.totalFiles; - summary.totalSizeBytes += stats.totalSizeBytes; - Object.assign(summary.byType, stats.byType); - Object.assign(summary.byExtension, stats.byExtension); - - if (stats.totalFiles > 0) { - folders.push({ - path: dirPath, - name: subDir, - type: classifyFolderType(subDir), - fileCount: stats.totalFiles, - modifiedAt: new Date().toISOString(), - }); - } - } catch { - /* directory doesn't exist, skip */ - } - } - - return [summary, folders]; -} - -interface DirStats { - totalFiles: number; - totalSizeBytes: number; - byType: Record; - byExtension: Record; -} - -async function scanDirectory( - dirPath: string, - _homeDir: string, - _relativePath: string, -): Promise { - const stats: DirStats = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; - +async function listDirectory(directory: string): Promise { try { - const entries = await readdir(dirPath, { withFileTypes: true }); + return (await readdir(directory, { withFileTypes: true })).sort((left, right) => + compareText(left.name, right.name), + ); + } catch { + return []; + } +} +/** isDirectory() is false for a symlink, so links (and anything they point to) are never walked. */ +function walkable(entry: Dirent): boolean { + return entry.isDirectory() && !entry.name.startsWith(".") && !SKIPPED_DIRECTORIES.has(entry.name); +} + +async function findProjectRoots(home: string): Promise { + const roots: string[] = []; + let visited = 0; + let level = [home]; + // Breadth first, so shallow projects win when a limit is reached. The scan folder itself is + // never a project: a home folder tracked by git still holds the projects below it. + for (let depth = 0; depth <= PROJECT_DEPTH && level.length > 0; depth += 1) { + const next: string[] = []; + for (const directory of level) { + if (roots.length >= ONBOARDING_SCAN_MAX_PROJECTS || visited >= MAX_DIRECTORIES) return roots; + visited += 1; + const entries = await listDirectory(directory); + if (depth > 0 && entries.some((entry) => entry.name === ".git")) { + roots.push(directory); + continue; + } + for (const entry of entries) { + if (walkable(entry)) next.push(path.join(directory, entry.name)); + } + } + level = next; + } + return roots; +} + +async function analyzeProject(root: string): Promise<{ languages: string[]; tools: string[] }> { + const counts = new Map(); + const tools = new Set(); + let files = 0; + const rootEntries = await listDirectory(root); + if (rootEntries.some((entry) => entry.name === ".github" && entry.isDirectory())) { + const github = await listDirectory(path.join(root, ".github")); + if (github.some((entry) => entry.name === "workflows" && entry.isDirectory())) { + tools.add("GitHub Actions"); + } + } + const visit = async (directory: string, depth: number, entries: Dirent[]): Promise => { for (const entry of entries) { - const fullPath = join(dirPath, entry.name); - - if (entry.isDirectory()) { - // Skip node_modules, .git, etc. - if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".venv") - continue; - const subStats = await scanDirectory(fullPath, _homeDir, _relativePath); - stats.totalFiles += subStats.totalFiles; - stats.totalSizeBytes += subStats.totalSizeBytes; - Object.assign(stats.byType, subStats.byType); - Object.assign(stats.byExtension, subStats.byExtension); - } else if (entry.isFile()) { - try { - const fileStat = await stat(fullPath); - stats.totalFiles++; - stats.totalSizeBytes += fileStat.size; - - // Classify by type - const type = classifyFileType(entry.name); - stats.byType[type] = (stats.byType[type] || 0) + 1; - - // Classify by extension - const ext = entry.name.split(".").pop()?.toLowerCase() || "none"; - stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1; - } catch { - /* permission denied */ + if (files >= MAX_FILES_PER_PROJECT) return; + if (entry.isFile()) { + files += 1; + const name = entry.name.toLowerCase(); + const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + const language = LANGUAGES.get(extension); + if (language) counts.set(language, (counts.get(language) ?? 0) + 1); + if (name === "dockerfile" || name.startsWith("dockerfile.") || COMPOSE_FILES.has(name)) { + tools.add("Docker"); } + if (extension === "tf") tools.add("Terraform"); + } else if (depth < MAX_FILE_DEPTH && walkable(entry)) { + const child = path.join(directory, entry.name); + await visit(child, depth + 1, await listDirectory(child)); } } - } catch { - /* permission denied */ - } - - return stats; + }; + await visit(root, 0, rootEntries); + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + const languages = [...counts.entries()] + .filter(([, count]) => count >= total * 0.1) + .sort(byCount) + .slice(0, MAX_LANGUAGES_PER_PROJECT) + .map(([language]) => language); + return { languages, tools: [...tools].sort() }; } -function classifyFileType(filename: string): string { - const ext = filename.split(".").pop()?.toLowerCase() || ""; - const codeExts = [ - "ts", - "tsx", - "js", - "jsx", - "py", - "go", - "rs", - "java", - "c", - "cpp", - "h", - "rb", - "php", - "swift", - "kt", - ]; - const docExts = ["md", "txt", "doc", "docx", "pdf", "rst", "tex"]; - const imageExts = ["png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "bmp"]; - const musicExts = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"]; - - if (codeExts.includes(ext)) return "code"; - if (docExts.includes(ext)) return "documents"; - if (imageExts.includes(ext)) return "images"; - if (musicExts.includes(ext)) return "music"; - if (["json", "yaml", "yml", "toml", "cfg", "ini", "env", "sh", "bash", "zsh"].includes(ext)) - return "config"; - return "other"; +/** Labels used by the most projects first, then by name. */ +function rankLabels(lists: string[][]): string[] { + const counts = new Map(); + for (const list of lists) + for (const label of list) counts.set(label, (counts.get(label) ?? 0) + 1); + return [...counts.entries()].sort(byCount).map(([label]) => label); } -function classifyFolderType(name: string): ProjectFolder["type"] { - const lower = name.toLowerCase(); - if (lower.includes("music")) return "music"; - if (lower.includes("picture") || lower.includes("image") || lower.includes("photo")) - return "media"; - if (lower.includes("document") || lower.includes("doc")) return "documents"; - if ( - lower.includes("project") || - lower.includes("code") || - lower.includes("src") || - lower.includes("dev") || - lower.includes("workspace") || - lower.includes("repo") - ) - return "code"; - return "workspace"; +/** Lists the projects below a real (symlink-free) folder path, and what they use. */ +export async function discoverOnboardingScan(home: string): Promise { + const roots = await findProjectRoots(home); + const analyzed = await Promise.all( + roots.map(async (root) => ({ root, ...(await analyzeProject(root)) })), + ); + const projects = analyzed + .map(({ root, languages, tools }) => ({ + name: path.basename(root), + path: path.relative(home, root), + languages, + tools, + })) + .sort((left, right) => compareText(left.path, right.path)) + .map((project, index) => ({ id: `project-${index}`, ...project })); + const facts: FoundFact[] = [ + ...rankLabels(projects.map((project) => project.languages)).map((label) => ({ + id: `language:${label}`, + label, + category: "work" as const, + value: `Codes in ${label}`, + })), + ...rankLabels(projects.map((project) => project.tools)).map((label) => ({ + id: `tool:${label}`, + label, + category: "tools" as const, + value: `Uses ${label}`, + })), + ].slice(0, ONBOARDING_SCAN_MAX_FACTS); + return { projects, facts }; } -// --------------------------------------------------------------------------- -// Phase 2: Git repository discovery -// --------------------------------------------------------------------------- - -async function discoverGitRepos(homeDir: string): Promise { - const repos: RepoInfo[] = []; - +/** + * The scan reads the server's filesystem, so only the deployment owner may scan, and only the + * folder the deployment configured. Null hides the step. + */ +export async function onboardingScanFolder( + actor: Pick, + scanHome: string | undefined, +): Promise { + if (!actor.isDeploymentOwner || !scanHome) return null; try { - const gitDirs = await findGitDirectories(homeDir); - - for (const repoDir of gitDirs) { - try { - const repo = await analyzeGitRepo(repoDir, homeDir); - if (repo) repos.push(repo); - } catch { - /* skip broken repos */ - } - } - } catch { - /* git not available */ - } - - return repos; -} - -async function findGitDirectories(dir: string, depth: number = 0): Promise { - if (depth > 4) return []; // Limit search depth - const results: string[] = []; - - try { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === ".git") { - results.push(dirname(fullPath)); - } else if ( - // Dot folders hold tools and caches (shell and editor plugins), not the person's projects. - !entry.name.startsWith(".") && - entry.name !== "node_modules" && - entry.name !== "dist" - ) { - results.push(...(await findGitDirectories(fullPath, depth + 1))); - } - } - } - } catch { - /* permission denied */ - } - - return results; -} - -async function analyzeGitRepo(repoDir: string, _homeDir: string): Promise { - try { - const name = repoDir.split("/").pop() || ""; - - // Get remote URL - const remoteResult = await spawnPromise("git", ["remote", "get-url", "origin"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const remote = remoteResult.stdout.trim() || null; - - // Get current branch - const branchResult = await spawnPromise("git", ["branch", "--show-current"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const branch = branchResult.stdout.trim() || "unknown"; - - // Get last commit - const commitResult = await spawnPromise("git", ["log", "-1", "--format=%H"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const lastCommit = commitResult.stdout.trim() || null; - - // Get language stats via git ls-files + wc - const languages = await getRepoLanguages(repoDir); - - // Get directory size - const sizeResult = await spawnPromise("du", ["-sb", repoDir]).catch(() => ({ stdout: "0" })); - const sizeBytes = parseInt(sizeResult.stdout.split("\t")[0] || "0", 10); - - return { path: repoDir, name, remote, branch, lastCommit, languages, sizeBytes }; + const home = await realpath(scanHome); + return (await stat(home)).isDirectory() ? home : null; } catch { return null; } } -async function getRepoLanguages(repoDir: string): Promise> { - const langs: Record = {}; - try { - const { stdout } = await spawnPromise("git", ["ls-files"], { cwd: repoDir }).catch(() => ({ - stdout: "", - })); - const files = stdout.trim().split("\n").filter(Boolean); - for (const file of files) { - const ext = file.split(".").pop()?.toLowerCase() || ""; - const langMap: Record = { - ts: "TypeScript", - tsx: "TypeScript", - js: "JavaScript", - jsx: "JavaScript", - py: "Python", - go: "Go", - rs: "Rust", - java: "Java", - c: "C", - cpp: "C++", - rb: "Ruby", - php: "PHP", - swift: "Swift", - kt: "Kotlin", - }; - const lang = langMap[ext] || ext || "unknown"; - langs[lang] = (langs[lang] || 0) + 1; - } - } catch { - /* no languages */ +function forgetExpiredScans(now: number): void { + for (const [id, scan] of scans) { + if (scan.finishedAt !== null && now - scan.finishedAt > SCAN_TTL_MS) scans.delete(id); } - return langs; } -// --------------------------------------------------------------------------- -// Phase 3: Profile compilation -// --------------------------------------------------------------------------- - -function compileProfile( - repos: RepoInfo[], - fileSummary: FileSummary, - _folders: ProjectFolder[], -): ProfileSummary { - const interests: string[] = []; - const skills: string[] = []; - const techStack: string[] = []; - const languageSet = new Set(); - - // Extract tech stack from repos - for (const repo of repos) { - for (const lang of Object.keys(repo.languages)) { - techStack.push(lang); - languageSet.add(lang); - } - } - - // Extract interests from file types - for (const [type, count] of Object.entries(fileSummary.byType)) { - if (count > 5) { - interests.push(`${type} (${count} files)`); - } - } - - // Derive skills from languages - const langToSkill: Record = { - TypeScript: "TypeScript/JavaScript", - Python: "Python", - Go: "Go", - Rust: "Rust", - Java: "Java", - C: "C/C++", - "C++": "C/C++", - Ruby: "Ruby", - Swift: "Swift/Kotlin", - PHP: "PHP", +export async function startOnboardingScan( + actor: Pick, + scanHome: string | undefined, +): Promise<{ scanId: string }> { + const home = await onboardingScanFolder(actor, scanHome); + if (!home) throw new ORPCError("FORBIDDEN"); + forgetExpiredScans(Date.now()); + const running = [...scans.values()].find( + (scan) => scan.ownerUserId === actor.userId && scan.status === "scanning", + ); + if (running) return { scanId: running.id }; + const scan: ScanRecord = { + id: randomUUID(), + ownerUserId: actor.userId, + home, + status: "scanning", + projects: [], + facts: [], + finishedAt: null, + applying: false, }; - for (const lang of techStack) { - const skill = langToSkill[lang]; - if (skill && !skills.includes(skill)) skills.push(skill); - } + scans.set(scan.id, scan); + void discoverOnboardingScan(home) + .then((findings) => { + scan.projects = findings.projects; + scan.facts = findings.facts; + scan.status = "complete"; + }) + .catch(() => { + scan.status = "failed"; + }) + .finally(() => { + scan.finishedAt = Date.now(); + }); + return { scanId: scan.id }; +} - // Deduplicate - const uniqueInterests = [...new Set(interests)].slice(0, 10); - const uniqueSkills = [...new Set(skills)].slice(0, 15); - const uniqueTech = [...new Set(techStack)].sort(); +/** Another account's scan reads as missing. */ +function ownedScan(scanId: string, userId: string): ScanRecord { + const scan = scans.get(scanId); + if (!scan || scan.ownerUserId !== userId) throw new ORPCError("NOT_FOUND"); + return scan; +} +export function onboardingScanStatus(scanId: string, userId: string): OnboardingScanStatus { + const scan = ownedScan(scanId, userId); + if (scan.status !== "complete") return { status: scan.status }; return { - interests: uniqueInterests, - skills: uniqueSkills, - workStyle: - uniqueTech.length > 3 - ? "multi-language" - : uniqueTech[0] - ? `${uniqueTech[0]}-focused` - : "general", - communication: "structured", - languages: Array.from(languageSet), - techStack: uniqueTech, + status: "complete", + projects: scan.projects.map(({ id, name, path }) => ({ id, name, path })), + facts: scan.facts.map(({ id, label }) => ({ id, label })), }; } -// --------------------------------------------------------------------------- -// Phase 4: Agent briefing -// --------------------------------------------------------------------------- +const MISSING_FOLDER = "That folder does not exist or is not readable."; -function generateBriefing( - repos: RepoInfo[], - profile: ProfileSummary, - fileSummary: FileSummary, - subAgentRoles: SubAgentRole[], -): string { - const lines: string[] = []; - lines.push(`# User Profile Briefing`); - lines.push(``); - lines.push(`## Technical Profile`); - lines.push(`- **Tech Stack:** ${profile.techStack.join(", ") || "N/A"}`); - lines.push(`- **Languages:** ${profile.languages.join(", ") || "N/A"}`); - lines.push(`- **Work Style:** ${profile.workStyle}`); - lines.push(``); - lines.push(`## Local Repositories (${repos.length} found)`); - for (const repo of repos) { - lines.push( - `- **${repo.name}** — ${repo.branch}, ${Object.values(repo.languages).reduce((a, b) => a + b, 0)} files, ${repo.remote || "no remote"}`, - ); +/** Resolves a found project again, so a folder swapped for a symlink cannot leave the scan folder. */ +async function confinedProjectFolder(home: string, relative: string): Promise { + const folder = await resolveProjectWorkspaceDirectory(path.join(home, relative)); + const inside = path.relative(home, folder); + if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) { + throw new InvalidProjectWorkspacePathError(MISSING_FOLDER); } - lines.push(``); - lines.push(`## File System Summary`); - lines.push(`- **Total Files:** ${fileSummary.totalFiles.toLocaleString()}`); - lines.push(`- **Total Size:** ${(fileSummary.totalSizeBytes / (1024 * 1024)).toFixed(0)} MB`); - lines.push( - `- **Categories:** ${Object.entries(fileSummary.byType) - .map(([t, c]) => `${t}(${c})`) - .join(", ")}`, - ); - lines.push(``); - lines.push(`## User Interests`); - lines.push(`- ${profile.interests.slice(0, 5).join("\n- ")}`); - lines.push(``); - lines.push(`## User Skills`); - lines.push(`- ${profile.skills.slice(0, 5).join("\n- ")}`); - lines.push(``); - lines.push(`## Assigned Specialist Roles`); - for (const role of subAgentRoles) { - lines.push(`### ${role.name}`); - lines.push(`- **Role:** ${role.role}`); - lines.push(`- **Description:** ${role.description}`); - lines.push(`- **Instructions:** ${role.instructions}`); - lines.push(`- **Skills:** ${role.skills.join(", ")}`); - lines.push(""); - } - lines.push(`---`); - lines.push( - `This profile should be used to tailor all future interactions, project recommendations, and agent assignments.`, - ); - return lines.join("\n"); + return folder; } -// --------------------------------------------------------------------------- -// Phase 5: Projects for discovered repositories -// --------------------------------------------------------------------------- - -/** Give each discovered repository a project linked to its folder, unless one already links it. */ -async function createProjects( - repos: RepoInfo[], - actor: ScanActor, +async function projectSpace( prisma: PrismaClient, -): Promise { - const linked = await prisma.space.findMany({ - where: { workspacePath: { not: null }, memberships: { some: { userId: actor.userId } } }, - select: { workspacePath: true }, - }); - const linkedFolders = new Set(linked.map((space) => space.workspacePath)); - const created: CreatedProject[] = []; - - for (const repo of repos) { - // Compare and link the real folder, so the next scan recognizes the projects of this one. - // Checking it before the project exists means a folder that cannot be linked leaves none. - const folder = await resolveProjectWorkspaceDirectory(repo.path).catch(() => null); - if (!folder || linkedFolders.has(folder)) continue; - let space: { id: string; name: string }; - try { - space = await createSpaceForMember(prisma, { - currentSpaceId: actor.spaceId, - userId: actor.userId, - name: basename(folder), - }); - } catch (error) { - if (error instanceof SpaceLimitError) break; - if (error instanceof InvalidSpaceNameError) continue; - throw error; - } - const workspace = await wireProjectWorkspace(prisma, { - spaceId: space.id, + actor: Pick, + name: string, + folder: string, +): Promise<{ id: string; name: string }> { + // Applying twice, or onboarding again, reuses the user's own space linked to this folder. + const existing = await prisma.space.findFirst({ + where: { workspacePath: folder, - }); - await ensureMainConversation(prisma, actor.userId, space.id); - created.push({ spaceId: space.id, name: space.name, path: workspace.path }); - } - - return created; -} - -// --------------------------------------------------------------------------- -// Phase 6: Sub-agent role creation -// --------------------------------------------------------------------------- - -interface SubAgentRole { - name: string; - role: string; - description: string; - instructions: string; - skills: string[]; -} - -function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubAgentRole[] { - const roles: SubAgentRole[] = []; - const hasCode = profile.languages?.length > 0; - const hasDesign = profile.interests?.some( - (i) => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"), - ); - const hasData = profile.interests?.some( - (i) => - i.toLowerCase().includes("data") || - i.toLowerCase().includes("analytics") || - i.toLowerCase().includes("database"), - ); - const hasDevOps = profile.interests?.some( - (i) => - i.toLowerCase().includes("infra") || - i.toLowerCase().includes("ops") || - i.toLowerCase().includes("deploy"), - ); - const hasMusic = profile.interests?.some((i) => i.toLowerCase().includes("music")); - const hasWriting = profile.interests?.some( - (i) => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog"), - ); - - if (hasCode) { - const primaryLang = profile.languages?.[0] || "TypeScript"; - roles.push({ - name: "Code Engineer", - role: "software-engineer", - description: `Specializes in ${primaryLang} development and code review`, - instructions: `You are the Code Engineer specialist. Your expertise is in ${profile.languages?.join(", ") || "software development"}. Review code, write tests, debug issues, and implement features. You have access to the user's local repositories and can run commands to build and test code.`, - skills: profile.skills.filter( - (s) => s.toLowerCase().includes("code") || s.toLowerCase().includes("program"), - ), - }); - } - - if (hasDesign) { - roles.push({ - name: "UI/UX Designer", - role: "ui-designer", - description: "Creates polished interfaces and design systems", - instructions: - "You are the UI/UX Designer specialist. Focus on creating beautiful, accessible interfaces. Review designs, suggest improvements, and ensure consistency across the user's projects.", - skills: ["design", "UI/UX"], - }); - } - - if (hasData) { - roles.push({ - name: "Data Analyst", - role: "data-analyst", - description: "Analyzes data, creates reports, and extracts insights", - instructions: - "You are the Data Analyst specialist. Process data from the user's projects, create visualizations, generate reports, and provide actionable insights.", - skills: ["data analysis", "statistics"], - }); - } - - if (hasDevOps) { - roles.push({ - name: "DevOps Engineer", - role: "devops-engineer", - description: "Manages infrastructure, deployments, and CI/CD", - instructions: - "You are the DevOps Engineer specialist. Handle deployments, manage infrastructure, configure CI/CD pipelines, and ensure system reliability.", - skills: ["DevOps", "infrastructure"], - }); - } - - if (hasMusic) { - roles.push({ - name: "Music Producer", - role: "music-producer", - description: "Assists with music production and audio engineering", - instructions: - "You are the Music Producer specialist. Help with music composition, audio engineering, mixing, and mastering. You understand the user's music projects and can provide expert advice.", - skills: ["music production", "audio engineering"], - }); - } - - if (hasWriting) { - roles.push({ - name: "Content Writer", - role: "content-writer", - description: "Creates and edits written content", - instructions: - "You are the Content Writer specialist. Write blog posts, documentation, articles, and other content for the user's projects and personal use.", - skills: ["writing", "content creation"], - }); - } - - // Always include a general assistant - roles.unshift({ - name: "General Assistant", - role: "general-assistant", - description: "Handles general tasks and coordinates other specialists", - instructions: `You are the General Assistant and team coordinator. Your role is to handle general queries, coordinate with other specialists (${ - roles - .slice(1) - .map((r) => r.name) - .join(", ") || "none yet" - }), and manage the user's daily workflow. You have access to all tools and can delegate tasks to specialists when needed.`, - skills: ["task management", "coordination"], + deletingAt: null, + memberships: { some: { userId: actor.userId } }, + }, + select: { id: true, name: true }, + orderBy: { createdAt: "asc" }, }); - - return roles; -} - -// --------------------------------------------------------------------------- -// Utility -// --------------------------------------------------------------------------- - -function spawnPromise( - cmd: string, - args: string[], - options?: { cwd?: string }, -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { ...options, timeout: 15000 }); - let stdout = ""; - let stderr = ""; - child.stdout?.on("data", (d: Buffer) => { - stdout += d.toString(); - }); - child.stderr?.on("data", (d: Buffer) => { - stderr += d.toString(); - }); - child.on("error", () => reject(new Error("spawn failed"))); - child.on("close", (code) => { - if (code === 0 || code === null) resolve({ stdout, stderr }); - else reject(new Error(`exit ${code}`)); - }); + if (existing) return existing; + const space = await createSpaceForMember(prisma, { + currentSpaceId: actor.spaceId, + userId: actor.userId, + name: name.slice(0, 60), }); + await wireProjectWorkspace(prisma, { spaceId: space.id, workspacePath: folder }); + // Like any new project, it starts with its own main agent. + await ensureMainConversation(prisma, actor.userId, space.id); + return space; } -// Keep old registry cleanup -setInterval(() => { - const cutoff = Date.now() - 3600000; - for (const [id, r] of scanRegistry) { - if (r.completedAt && r.completedAt.getTime() < cutoff) scanRegistry.delete(id); +function toRpcError(error: unknown): unknown { + if ( + error instanceof SpaceLimitError || + error instanceof InvalidSpaceNameError || + error instanceof InvalidProjectWorkspacePathError || + error instanceof InvalidUserFactError + ) { + return new ORPCError("BAD_REQUEST", { message: error.message }); } -}, 60000); + return error; +} + +/** Creates a project space per chosen project and saves the chosen facts to the profile. */ +export async function applyOnboardingScan( + prisma: PrismaClient, + actor: Pick, + scanHome: string | undefined, + input: OnboardingScanApplyInput, +): Promise { + const scan = ownedScan(input.scanId, actor.userId); + if (scan.status !== "complete" || scan.applying) throw new ORPCError("CONFLICT"); + scan.applying = true; + try { + // Ownership or the configured folder can change after the scan; check again before writing. + if ((await onboardingScanFolder(actor, scanHome)) !== scan.home) { + throw new ORPCError("FORBIDDEN"); + } + const projectIds = new Set(input.projectIds); + const factIds = new Set(input.factIds); + const projects = scan.projects.filter((project) => projectIds.has(project.id)); + const facts = scan.facts.filter((fact) => factIds.has(fact.id)); + // Every chosen folder is checked before anything is written. + const chosen = await Promise.all( + projects.map(async (project) => ({ + name: project.name, + folder: await confinedProjectFolder(scan.home, project.path), + })), + ); + const added = + facts.length > 0 + ? await updateUserProfile(prisma, actor.userId, { + addFacts: facts.map(({ category, value }) => ({ + category, + value, + source: "onboarding" as const, + })), + }) + : []; + const spaces: Array<{ id: string; name: string }> = []; + for (const { name, folder } of chosen) + spaces.push(await projectSpace(prisma, actor, name, folder)); + return { spaces, factsAdded: added.filter((fact) => fact.created).length }; + } catch (error) { + throw toRpcError(error); + } finally { + scan.applying = false; + } +} diff --git a/apps/api/src/onboarding.test.ts b/apps/api/src/onboarding.test.ts index 38189ea..4d68859 100644 --- a/apps/api/src/onboarding.test.ts +++ b/apps/api/src/onboarding.test.ts @@ -1,6 +1,7 @@ import type * as db from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import { chooseFocus, markAppConnected } from "./onboarding.js"; +import { focusCard } from "./onboarding-intake.js"; const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>); vi.mock("@rakazo/db", async (original) => ({ @@ -13,16 +14,16 @@ vi.mock("@rakazo/db", async (original) => ({ })); function fixture(catalog: unknown[]) { posted.length = 0; + // A specialist's focus card, waiting for an answer. + const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]); const tx = { $executeRaw: vi.fn(), - message: { - findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]), - update: vi.fn(), - }, + message: { findMany: pending, update: vi.fn() }, }; const deps = { prisma: { bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) }, + message: { findMany: pending }, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)), }, events: { notify: vi.fn() }, @@ -39,17 +40,16 @@ function fixture(catalog: unknown[]) { describe("onboarding connection suggestions", () => { it("does not invent authorization cards when no connector has an app catalog", async () => { const { deps, actor } = fixture([]); - await chooseFocus(deps, actor, "bot", "day"); - expect(posted.flatMap((message) => message.blocks)).not.toContainEqual( - expect.objectContaining({ kind: "app_connect" }), - ); - expect(posted.length).toBeGreaterThan(0); + await chooseFocus(deps, actor, "bot", { optionId: "day" }); + // The answered card is all that changes; nothing is said around it. + expect(posted).toEqual([]); + expect(deps.events.notify).toHaveBeenCalled(); }); it("uses the available connector and omits unavailable apps", async () => { const { deps, actor } = fixture([ { connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null }, ]); - await chooseFocus(deps, actor, "bot", "day"); + await chooseFocus(deps, actor, "bot", { optionId: "day" }); expect( posted .flatMap((message) => message.blocks) @@ -57,6 +57,8 @@ describe("onboarding connection suggestions", () => { ).toEqual([ expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }), ]); + // The cards speak for themselves. + expect(posted).toHaveLength(1); }); }); diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index 71a4759..0d33eed 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -1,67 +1,47 @@ +import { ORPCError } from "@orpc/server"; +import type { JobPublisher } from "@rakazo/adapter-kit"; import type { ConnectorRegistry } from "@rakazo/adapters"; -import type { Actor, MessageBlock } from "@rakazo/contracts"; +import { getCoachRhythm } from "@rakazo/adapters"; +import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { featuredConnectorProvidersMatch } from "@rakazo/core"; import { appendEventInTransaction, createThreadMessageInTransaction, ensureMainConversation, IsolationError, + isPersonalMainBot, type Prisma, type PrismaClient, type ThreadEvents, } from "@rakazo/db"; +import type { ChoiceBlock, FocusOption, IntakeAnswer, IntakeContext } from "./onboarding-intake.js"; +import { + closingNote, + FOCUS_OPTIONS, + focusCard, + intakeContext, + intakeStep, + nextIntakeStep, + readIntakeAnswer, + secondaryOption, +} from "./onboarding-intake.js"; /** - * First-run conversational onboarding, seeded deterministically into the bot's - * thread: greeting, a focus choice, and available app cards the user authorizes - * inline. Focus must not rename the bot. No model tokens are spent. The main bot - * talks in the main conversation, where onboarding lands. + * Conversational onboarding, posted deterministically into a bot's thread; no model tokens are + * spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main + * conversation, one card after each answer. Any other bot gets one focus card that suggests + * apps the user authorizes inline. Focus never renames a bot. */ type OnboardingDeps = { prisma: PrismaClient; events: ThreadEvents; connectors: ConnectorRegistry; + jobs: JobPublisher; }; -type FocusOption = { - id: string; - letter: string; - label: string; - summary: string; - apps: string[]; -}; - -const FOCUS_OPTIONS: FocusOption[] = [ - { - id: "day", - letter: "A", - label: "Day-to-day work", - summary: "Slack, calendar, and email", - apps: ["slack", "gmail", "googlecalendar"], - }, - { - id: "inbox", - letter: "B", - label: "Inbox & email", - summary: "email and calendar", - apps: ["gmail", "googlecalendar", "slack"], - }, - { - id: "research", - letter: "C", - label: "Research & writing", - summary: "the web, notes, and docs", - apps: ["hackernews", "notion", "googledocs"], - }, - { - id: "crew", - letter: "D", - label: "Specialist crew", - summary: "a coordinated team of focused Oos", - apps: ["slack", "gmail", "googlecalendar"], - }, -]; +type Target = { spaceId: string; botId: string; threadId: string }; const APP_DESCRIPTIONS: Record = { slack: "Search, read, and send messages.", @@ -85,74 +65,102 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin return { bot, thread: bot.thread }; } -async function post( - deps: OnboardingDeps, - target: { spaceId: string; botId: string; threadId: string }, +/** + * Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows + * that reference the thread from another connection (the coach routines do). + */ +async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise { + await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`; +} + +async function postInTransaction( + tx: Prisma.TransactionClient, + target: Target, blocks: MessageBlock[], -): Promise { - const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - const message = await createThreadMessageInTransaction(tx, { - threadId: target.threadId, - role: "bot", - blocks, - botId: target.botId, - }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.created", - payload: { messageId: message.id, role: "bot", blocks }, - }); - return { message, event }; +): Promise<{ seq: number }> { + const message = await createThreadMessageInTransaction(tx, { + threadId: target.threadId, + role: "bot", + blocks, + botId: target.botId, + }); + return appendEventInTransaction(tx, { + spaceId: target.spaceId, + threadId: target.threadId, + botId: target.botId, + type: "thread.message.created", + payload: { messageId: message.id, role: "bot", blocks }, + }); +} + +async function updateBlocksInTransaction( + tx: Prisma.TransactionClient, + target: Target, + messageId: string, + blocks: MessageBlock[], +): Promise<{ seq: number }> { + await tx.message.update({ where: { id: messageId }, data: { blocks } }); + return appendEventInTransaction(tx, { + spaceId: target.spaceId, + threadId: target.threadId, + botId: target.botId, + type: "thread.message.updated", + payload: { messageId, role: "bot", blocks }, }); - await deps.events.notify(target.threadId, committed.event.seq); - return committed.message.id; } async function updateBlocks( deps: OnboardingDeps, - target: { spaceId: string; botId: string; threadId: string }, + target: Target, messageId: string, blocks: MessageBlock[], ): Promise { - const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.message.update({ where: { id: messageId }, data: { blocks } }); - return appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId, role: "bot", blocks }, - }); - }); + const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) => + updateBlocksInTransaction(tx, target, messageId, blocks), + ); await deps.events.notify(target.threadId, event.seq); } /** Sentinel answerId for a focus card the user dismissed without choosing. */ export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed"; +/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */ export async function startOnboarding( deps: OnboardingDeps, actor: Actor, botId: string, ): Promise { - const { thread } = await requireBotThread(deps, actor, botId); - const existing = await deps.prisma.message.count({ where: { threadId: thread.id } }); - if (existing > 0) return; - // Fresh chats start empty (same as web). The focus card is posted later via - // promptFocus so non-first bots can wait ~10s for free typing, or skip if the - // user already engaged. + await requireBotThread(deps, actor, botId); } -function messageHasChoice(blocks: MessageBlock[]): boolean { - return blocks.some((block) => block.kind === "choice"); +function isChoice(block: MessageBlock): block is ChoiceBlock { + return block.kind === "choice"; } -function messageHasPendingChoice(blocks: MessageBlock[]): boolean { - return blocks.some((block) => block.kind === "choice" && !block.answerId); +function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined { + return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId); } +/** The newest card in the thread that still waits for an answer, however long ago it came. */ +async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) { + const messages = await client.message.findMany({ + where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } }, + select: { id: true, blocks: true }, + orderBy: { seq: "desc" }, + take: 20, + }); + for (const message of messages) { + const blocks = message.blocks as MessageBlock[]; + const card = pendingChoice(blocks); + if (card) return { messageId: message.id, blocks, card }; + } + return null; +} + +/** + * Posts the first card once. The main bot starts the onboarding questions, one set per user; + * any other bot posts its focus card only while its thread is still idle. + */ export async function promptFocus( deps: OnboardingDeps, actor: Actor, @@ -160,44 +168,32 @@ export async function promptFocus( ): Promise { const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; - const blocks: MessageBlock[] = [ - { - kind: "choice", - question: "What do you want me on first?", - options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })), - }, - ]; - // Check + insert + event in one transaction so concurrent promptFocus calls - // cannot duplicate cards, and a concurrent user send cannot publish first. + // The intake runs with the personal main agent; a project's main bot starts like any other. + const main = await isPersonalMainBot(deps.prisma, bot); + // Check + insert + event in one transaction so concurrent callers cannot duplicate cards, + // and a concurrent user send cannot publish first. const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - // Serialize concurrent promptFocus callers on this thread before the gate check. - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; + await lockThread(tx, thread.id); + if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) { + return null; + } const recent = await tx.message.findMany({ where: { threadId: thread.id }, select: { role: true, blocks: true }, - orderBy: { createdAt: "asc" }, }); - if (recent.some((message) => message.role === "user")) return null; - if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null; - const message = await createThreadMessageInTransaction(tx, { - threadId: target.threadId, - role: "bot", - blocks, - botId: target.botId, - }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.created", - payload: { messageId: message.id, role: "bot", blocks }, - }); - return { message, event }; + if (!main && recent.some((message) => message.role === "user")) return null; + if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null; + if (!main) return postInTransaction(tx, target, [focusCard()]); + const first = intakeStep("focus"); + await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } }); + return postInTransaction(tx, target, [ + first.card(await loadIntakeContext(tx, actor.userId, new Date())), + ]); }); - if (!committed) return; - await deps.events.notify(target.threadId, committed.event.seq); + if (committed) await deps.events.notify(target.threadId, committed.seq); } +/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */ export async function dismissFocus( deps: OnboardingDeps, actor: Actor, @@ -205,81 +201,128 @@ export async function dismissFocus( ): Promise { const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; - const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; - const recent = await tx.message.findMany({ - where: { threadId: thread.id }, - orderBy: { createdAt: "asc" }, - }); - const pending = recent.find((message) => - messageHasPendingChoice(message.blocks as MessageBlock[]), + const pending = await findPendingCard(deps.prisma, thread.id); + const skip = pending?.card.step ? secondaryOption(pending.card) : undefined; + if (pending?.card.step && skip) { + await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id }); + return; + } + const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await lockThread(tx, thread.id); + const current = await findPendingCard(tx, thread.id); + if (!current || current.card.step) return null; + const blocks = current.blocks.map((block) => + block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block, ); - if (!pending) return null; - const blocks = (pending.blocks as MessageBlock[]).map((block) => - block.kind === "choice" && !block.answerId - ? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID } - : block, - ); - await tx.message.update({ where: { id: pending.id }, data: { blocks } }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId: pending.id, role: "bot", blocks }, - }); - return { messageId: pending.id, blocks, event }; + return updateBlocksInTransaction(tx, target, current.messageId, blocks); }); - if (!claimed) return; - await deps.events.notify(target.threadId, claimed.event.seq); + if (event) await deps.events.notify(target.threadId, event.seq); } +export interface ChooseInput { + step?: OnboardingStep; + optionId: string; + text?: string; + canEnablePush?: boolean; +} + +/** + * Answers the pending card. The answer is checked against that card, a stale answer (another + * card is pending by now) is ignored, and everything that follows is committed together with + * it: the focus card's app suggestions, then the next onboarding question or the closing line. + */ export async function chooseFocus( deps: OnboardingDeps, actor: Actor, botId: string, - optionId: string, + input: ChooseInput, ): Promise { - const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId); - if (!option) throw new IsolationError(); const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; + const pending = await findPendingCard(deps.prisma, thread.id); + if (!pending || pending.card.step !== input.step) return; + const answer = readIntakeAnswer(pending.card, input.optionId, input.text); + if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" }); + const focus = + !pending.card.step || pending.card.step === "focus" + ? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id) + : undefined; + // Fetched before the transaction: the connector catalog can take seconds. + const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus) : []; - const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; - const recent = await tx.message.findMany({ - where: { threadId: thread.id }, - orderBy: { createdAt: "asc" }, - }); - const pending = recent.find((message) => - messageHasPendingChoice(message.blocks as MessageBlock[]), - ); - if (!pending) return null; - const blocks = (pending.blocks as MessageBlock[]).map((block) => - block.kind === "choice" ? { ...block, answerId: option.id } : block, - ); - await tx.message.update({ where: { id: pending.id }, data: { blocks } }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId: pending.id, role: "bot", blocks }, - }); - return { messageId: pending.id, blocks, event }; + const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await lockThread(tx, thread.id); + const current = await findPendingCard(tx, thread.id); + if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null; + const onboarding = current.card.step + ? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }) + : null; + if (current.card.step) { + if (onboarding?.step !== current.card.step) return null; + await intakeStep(current.card.step).save( + { prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId }, + answer, + ); + } + const answered = answeredCard(current.card, answer); + const blocks = current.blocks.map((block) => (block === current.card ? answered : block)); + let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks); + for (const message of followUp) last = await postInTransaction(tx, target, message); + if (current.card.step) { + last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush); + } + return last; }); - if (!claimed) return; - await deps.events.notify(target.threadId, claimed.event.seq); + if (event) await deps.events.notify(target.threadId, event.seq); +} - // Keep the name and title the user chose when creating the bot; the focus - // step only suggests apps, it must not rename the bot. - await post(deps, target, [ - { - kind: "text", - text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`, - }, - ]); +function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock { + return answer.option + ? { ...card, answerId: answer.option.id } + : { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text }; +} +async function loadIntakeContext( + tx: Prisma.TransactionClient, + userId: string, + now: Date, +): Promise { + const profile = await tx.userProfile.findUnique({ + where: { userId }, + select: { locale: true, timeZone: true }, + }); + return intakeContext(profile, now); +} + +/** Posts the next onboarding question, or the closing line once every step is answered. */ +async function askNext( + deps: OnboardingDeps, + tx: Prisma.TransactionClient, + target: Target, + userId: string, + answeredStep: OnboardingStep, + canEnablePush = false, +): Promise<{ seq: number }> { + const now = new Date(); + const context = await loadIntakeContext(tx, userId, now); + const next = nextIntakeStep(answeredStep, { canEnablePush }); + if (next) { + await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } }); + return postInTransaction(tx, target, [next.card(context)]); + } + // The rhythm step saved its answer outside this transaction, so the routines are readable. + const rhythm = await getCoachRhythm(deps.prisma, userId); + await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } }); + return postInTransaction(tx, target, [closingNote(rhythm, context)]); +} + +/** The apps worth connecting for a focus, as cards; they need no words around them. */ +async function focusFollowUp( + deps: OnboardingDeps, + actor: Actor, + botId: string, + option: FocusOption, +): Promise { const providers = deps.connectors.managedProviders(); const catalog = ( await Promise.all( @@ -290,14 +333,14 @@ export async function chooseFocus( traceId: "onboarding.choose", spaceId: actor.spaceId, userId: actor.userId, - botId: bot.id, + botId, signal: AbortSignal.timeout(15_000), }) .catch(() => []), ), ) ).flat(); - const cards: MessageBlock[] = option.apps.flatMap((slug) => { + const cards = option.apps.flatMap((slug) => { const entry = catalog.find( (item) => item.slug.toLowerCase() === slug.toLowerCase() || @@ -316,27 +359,7 @@ export async function chooseFocus( }, ]; }); - if (!cards.length) { - await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]); - return; - } - const cardNames = cards - .map((card) => (card.kind === "app_connect" ? card.name : "")) - .filter(Boolean); - const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`; - await post(deps, target, [ - { - kind: "text", - text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`, - }, - ]); - await post(deps, target, cards); - await post(deps, target, [ - { - kind: "text", - text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`, - }, - ]); + return cards.length > 0 ? [cards] : []; } export async function markAppConnected( @@ -376,7 +399,3 @@ export async function markAppConnected( await updateBlocks(deps, target, message.id, next); } } - -function capitalize(value: string): string { - return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value; -} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index c859344..845ffbc 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -12,6 +12,7 @@ import type { } from "@rakazo/adapter-kit"; import { computerControlExpireJobKey, + MemoryDocumentTooLargeError, messagingDeliverJob, routineJobKey, routineWakeupJob, @@ -28,6 +29,7 @@ import type { MemoryProviderResolver, PiOAuthLogins, RemoteConnectorDependencies, + VapidKeys, } from "@rakazo/adapters"; import { acquireComputerExecutionLease, @@ -44,7 +46,6 @@ import { computerSupportsUpdate, computerUpdateView, createVoiceProvider, - deletePushToken, deploymentAutoReviewDefault, destroyBot, displayBotWorkspacePath, @@ -53,6 +54,7 @@ import { filterTeamWorkspaceEntries, hasActiveComputerControl, isAutoReviewCheckerConfigured, + isBlockedHostname, isComputerScreenUnavailable, isProjectWorkspacePath, isSandboxGoneError, @@ -75,7 +77,6 @@ import { resolveAutoReviewChecker, resolveBotWorkspacePath, sanitizeComposioError, - savePushToken, scheduleComputerControlExpiry, scheduleComputerSleep, screenLeaseIdForRun, @@ -100,6 +101,7 @@ import type { } from "@rakazo/contracts"; import { appContract, + CoachRoutineKeySchema, IntegrationProviderIdSchema, OPENAI_COMPATIBLE_PROVIDER_ID, } from "@rakazo/contracts"; @@ -132,6 +134,7 @@ import { findModelCredential, findSpaceModelTier, formatMessagingLinkCode, + getNotificationSettings, InvalidProjectWorkspacePathError, InvalidSpaceNameError, IsolationError, @@ -145,6 +148,8 @@ import { parseComputerMode, projectWorkspaceDto, releaseSpaceDeletionClaim, + removePushSubscription, + removePushSubscriptionsOfKind, renewSpaceDeletionClaim, requireMembership, resolveProjectWorkspaceDirectory, @@ -153,8 +158,10 @@ import { SpaceLimitError, SpaceNotEmptyError, SpaceNotFoundError, + savePushSubscription, selectSpaceModelPreference, selectSpaceVoicePreference, + updateNotificationSettings, wireProjectWorkspace, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; @@ -163,11 +170,13 @@ import { createAgentSkillsService } from "./agent-skills.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; +import { createCoachService } from "./coach.js"; import { executionBlocksUserTakeover, resolveBusyBotName, toComputerStatus, } from "./computer-status.js"; +import { recordConnectedApp } from "./connected-app-facts.js"; import { listInbox } from "./inbox.js"; import { searchIntegrationCatalog } from "./integration-catalog.js"; import { @@ -199,7 +208,12 @@ import { promptFocus, startOnboarding, } from "./onboarding.js"; -import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; +import { + applyOnboardingScan, + onboardingScanFolder, + onboardingScanStatus, + startOnboardingScan, +} from "./onboarding-scan.js"; import { listSpaceRuns } from "./runs.js"; import { addScreenProxyCapability } from "./screen-proxy.js"; import { querySpaceSearch } from "./search.js"; @@ -305,6 +319,11 @@ async function reconcilePendingConnections( : []), ]; if (updates.length > 0) await prisma.$transaction(updates); + const connected = new Set(sync.connectIds); + for (const row of rows) { + if (row.status !== "pending" || !connected.has(row.id)) continue; + await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider }); + } } /** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */ @@ -478,6 +497,8 @@ export interface RouterDeps { secrets: EncryptedSecretStore; oauthLogins: PiOAuthLogins; integrationSettings?: IntegrationProviderSettings; + /** The deployment's web push key pair; without it browsers cannot subscribe. */ + vapid?: Pick; composio?: ComposioProvider; mcpOAuth?: McpOAuthBroker; connectors: ConnectorRegistry; @@ -545,9 +566,18 @@ function mapSpaceLifecycleError(error: unknown): unknown { } export function createRouter(deps: RouterDeps) { - const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>(); + const os = implement(appContract).$context<{ + actor: Actor | null; + signal?: AbortSignal; + userAgent?: string; + }>(); const repos = createRepos(deps.prisma); - const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors }; + const onboardingDeps = { + prisma: deps.prisma, + events: deps.events, + connectors: deps.connectors, + jobs: deps.jobs, + }; const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets); const groupRepos = createGroupRepos(deps.prisma); const projectGoalRepos = createProjectGoalRepos(deps.prisma); @@ -560,6 +590,7 @@ export function createRouter(deps: RouterDeps) { dataDir: deps.dataDir, }); const agentSkills = createAgentSkillsService(deps.prisma); + const coach = createCoachService(deps); const authed = os.use(async ({ context, next }) => { if (!context.actor) throw new ORPCError("UNAUTHORIZED"); @@ -599,12 +630,57 @@ export function createRouter(deps: RouterDeps) { profile: { get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)), update: authed.profile.update.handler(({ context, input }) => - updateProfile(deps.prisma, context.actor, input), + updateProfile(deps, context.actor, input), ), forget: authed.profile.forget.handler(({ context, input }) => - forgetProfile(deps.prisma, context.actor, input), + forgetProfile(deps, context.actor, input), ), }, + coach: { + today: authed.coach.today.handler(({ context }) => coach.today(context.actor)), + goals: { + list: authed.coach.goals.list.handler(({ context, input }) => + coach.listGoals(context.actor, input), + ), + create: authed.coach.goals.create.handler(({ context, input }) => + coach.createGoal(context.actor, input), + ), + update: authed.coach.goals.update.handler(({ context, input }) => + coach.updateGoal(context.actor, input), + ), + remove: authed.coach.goals.remove.handler(({ context, input }) => + coach.removeGoal(context.actor, input), + ), + }, + habits: { + list: authed.coach.habits.list.handler(({ context, input }) => + coach.listHabits(context.actor, input), + ), + create: authed.coach.habits.create.handler(({ context, input }) => + coach.createHabit(context.actor, input), + ), + update: authed.coach.habits.update.handler(({ context, input }) => + coach.updateHabit(context.actor, input), + ), + remove: authed.coach.habits.remove.handler(({ context, input }) => + coach.removeHabit(context.actor, input), + ), + }, + checkIns: { + log: authed.coach.checkIns.log.handler(({ context, input }) => + coach.logCheckIn(context.actor, input), + ), + list: authed.coach.checkIns.list.handler(({ context, input }) => + coach.listCheckIns(context.actor, input), + ), + }, + rhythm: { + get: authed.coach.rhythm.get.handler(({ context }) => coach.getRhythm(context.actor)), + set: authed.coach.rhythm.set.handler(({ context, input }) => + coach.setRhythm(context.actor, input), + ), + }, + }, spaces: { list: authed.spaces.list.handler(async ({ context }) => spaceNavigationDto(deps, context.actor, repos, groupRepos), @@ -2515,21 +2591,28 @@ export function createRouter(deps: RouterDeps) { }, }); if (!doc) throw new IsolationError(); - const updated = await deps.memory.commit( - { - scope: doc.scope as "bot" | "user", - botId: doc.botId ?? undefined, - path: doc.path, - content: input.content, - }, - { - operationId: "mem", - traceId: "mem", - spaceId: context.actor.spaceId, - userId: context.actor.userId, - signal: new AbortController().signal, - }, - ); + const updated = await deps.memory + .commit( + { + scope: doc.scope as "bot" | "user", + botId: doc.botId ?? undefined, + path: doc.path, + content: input.content, + }, + { + operationId: "mem", + traceId: "mem", + spaceId: context.actor.spaceId, + userId: context.actor.userId, + signal: new AbortController().signal, + }, + ) + .catch((error: unknown) => { + if (error instanceof MemoryDocumentTooLargeError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + }); return { id: updated.id, scope: doc.scope as "bot" | "user", @@ -3505,7 +3588,7 @@ export function createRouter(deps: RouterDeps) { return { ok: true as const }; }), choose: authed.onboarding.choose.handler(async ({ context, input }) => { - await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId); + await chooseFocus(onboardingDeps, context.actor, input.botId, input); return { ok: true as const }; }), dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => { @@ -3522,16 +3605,18 @@ export function createRouter(deps: RouterDeps) { ); return { ok: true as const }; }), - scan: authed.onboarding.scan.handler(async ({ context, input }) => { - const { scanId } = await startOnboardingScan( - { actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome }, - deps.prisma, - ); - return { scanId }; - }), - scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => { - return await getScanStatus(input.scanId, context.actor.userId); - }), + scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({ + folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome), + })), + scan: authed.onboarding.scan.handler(({ context }) => + startOnboardingScan(context.actor, deps.env.onboardingScanHome), + ), + scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => + onboardingScanStatus(input.scanId, context.actor.userId), + ), + applyScan: authed.onboarding.applyScan.handler(({ context, input }) => + applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input), + ), }, integrationSetup: { get: authed.integrationSetup.get.handler(async ({ context }) => { @@ -3804,6 +3889,13 @@ export function createRouter(deps: RouterDeps) { } throw new IsolationError(); } + if (!auth.authorizationUrl) { + await recordConnectedApp(deps.prisma, { + userId: context.actor.userId, + connectorId: input.connectorId, + provider: input.provider, + }); + } return { connectionId: row.id, authorizationUrl: auth.authorizationUrl }; } catch (error) { if (error instanceof IsolationError) throw error; @@ -4109,6 +4201,13 @@ export function createRouter(deps: RouterDeps) { }, { timeout: 60_000 }, ); + if (row.status === "connected") { + await recordConnectedApp(deps.prisma, { + userId: context.actor.userId, + connectorId: row.connectorId, + provider: row.provider, + }); + } } return { id: row.id, @@ -4865,13 +4964,53 @@ export function createRouter(deps: RouterDeps) { }, notifications: { registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => { - await savePushToken(deps.dataDir, context.actor.userId, input.token); + await savePushSubscription(deps.prisma, context.actor.userId, { + kind: "expo", + endpoint: input.token, + userAgent: context.userAgent, + }); return { ok: true as const }; }), + // The mobile app signs out without naming its token, so all of its tokens go. unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => { - await deletePushToken(deps.dataDir, context.actor.userId); + await removePushSubscriptionsOfKind(deps.prisma, context.actor.userId, "expo"); return { ok: true as const }; }), + settings: { + get: authed.notifications.settings.get.handler(({ context }) => + getNotificationSettings(deps.prisma, context.actor.userId), + ), + update: authed.notifications.settings.update.handler(({ context, input }) => + updateNotificationSettings(deps.prisma, context.actor.userId, input), + ), + }, + webPush: { + config: authed.notifications.webPush.config.handler(async () => { + const publicKey = (await deps.vapid?.publicKey()) ?? null; + return { available: publicKey !== null, publicKey }; + }), + subscribe: authed.notifications.webPush.subscribe.handler(async ({ context, input }) => { + if (isBlockedHostname(new URL(input.endpoint).hostname.replace(/^\[|\]$/g, ""))) { + throw new ORPCError("BAD_REQUEST", { + message: "Endpoint must be a public push service", + }); + } + await savePushSubscription(deps.prisma, context.actor.userId, { + kind: "webpush", + endpoint: input.endpoint, + p256dh: input.keys.p256dh, + auth: input.keys.auth, + userAgent: context.userAgent, + }); + return { ok: true as const }; + }), + unsubscribe: authed.notifications.webPush.unsubscribe.handler( + async ({ context, input }) => { + await removePushSubscription(deps.prisma, context.actor.userId, input.endpoint); + return { ok: true as const }; + }, + ), + }, }, search: { query: authed.search.query.handler(async ({ context, input }) => ({ @@ -5179,6 +5318,7 @@ function spaceGroupDto(group: SpaceGroup): SpaceGroup { sectionId: group.sectionId, members: group.members, preview: group.preview, + ...(group.previewBlock ? { previewBlock: group.previewBlock } : {}), unread: group.unread, updatedAt: group.updatedAt, }; @@ -5562,6 +5702,7 @@ function mapRoutine(row: { lastRunAt: Date | null; nextRunAt: Date | null; createdAt: Date; + coachKey: string | null; }) { return { id: row.id, @@ -5578,6 +5719,7 @@ function mapRoutine(row: { lastRunAt: row.lastRunAt?.toISOString() ?? null, nextRunAt: row.nextRunAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), + coachKey: CoachRoutineKeySchema.safeParse(row.coachKey).data ?? null, }; } diff --git a/apps/api/src/thread-message-pages.postgres.test.ts b/apps/api/src/thread-message-pages.postgres.test.ts new file mode 100644 index 0000000..b5b11e7 --- /dev/null +++ b/apps/api/src/thread-message-pages.postgres.test.ts @@ -0,0 +1,143 @@ +import type { MessageBlock } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { createDb, provisionMessagingIdentity } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { loadMessagePage } from "./thread-message-pages.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +describePostgres("loadMessagePage (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + let identity: { userId: string; spaceId: string; botId: string; threadId: string }; + let userRunId: string; + let peerRunId: string; + let seq = 0; + + beforeAll(async () => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + identity = await provisionMessagingIdentity( + prisma, + { provider: "sendblue", address: `+1555${Date.now().toString().slice(-7)}` }, + { signupsEnabled: undefined, signupAllowlist: undefined }, + ); + const { spaceId, botId, threadId, userId } = identity; + const owner = { spaceId, botId, threadId, userId }; + const run = async (trigger: string) => { + const task = await prisma.task.create({ + data: { ...owner, prompt: "fixture", status: "completed" }, + }); + const created = await prisma.run.create({ + data: { ...owner, taskId: task.id, status: "completed", trigger }, + }); + return created.id; + }; + userRunId = await run("user_message"); + peerRunId = await run("bot_message"); + seq = + ( + await prisma.message.aggregate({ + where: { threadId: identity.threadId }, + _max: { seq: true }, + }) + )._max.seq ?? 0; + }); + + afterAll(async () => { + if (identity) { + await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } }); + await prisma.organization.deleteMany({ where: { id: identity.spaceId } }); + await prisma.user.deleteMany({ where: { id: identity.userId } }); + } + await close?.(); + }); + + async function message(id: string, runId: string | null, blocks: MessageBlock[]) { + seq += 1; + await prisma.message.create({ + data: { + id: `${identity.threadId}-${id}`, + threadId: identity.threadId, + seq, + role: "bot", + runId, + blocks, + }, + }); + return seq; + } + + const ids = (page: Awaited>) => + page.messages.map((row) => row.id.slice(identity.threadId.length + 1)); + + it("fills a page with visible rows across a long stretch of hidden peer activity", async () => { + await message("older", userRunId, [{ kind: "text", text: "Older answer" }]); + await message("newer", userRunId, [{ kind: "text", text: "Newer answer" }]); + for (let index = 0; index < 30; index += 1) { + await message(`peer-steps-${index}`, peerRunId, [ + { kind: "steps", steps: [{ label: "Working", count: 1 }] }, + ]); + } + await message("peer-receipt", peerRunId, [ + { kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." }, + ]); + await message("peer-reply", peerRunId, [{ kind: "text", text: "Peer summary" }]); + await message("orphan-run", "run-that-no-longer-exists", [{ kind: "text", text: "Kept" }]); + + const first = await loadMessagePage(prisma, identity.threadId, undefined, 3); + expect(ids(first)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]); + + const second = await loadMessagePage(prisma, identity.threadId, first.olderCursor!, 2); + expect(ids(second)).toEqual(["older", "newer"]); + + const peerView = await loadMessagePage( + prisma, + identity.threadId, + undefined, + 3, + undefined, + true, + ); + expect(ids(peerView)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]); + const peerOlder = await loadMessagePage( + prisma, + identity.threadId, + peerView.olderCursor!, + 1, + undefined, + true, + ); + expect(ids(peerOlder)).toEqual(["peer-steps-29"]); + }); + + it("skips a receipt-only page for web but returns it when receipts are displayed", async () => { + await message("before-receipts", userRunId, [{ kind: "text", text: "Answer" }]); + await message("sent", userRunId, [ + { kind: "bot_message_sent", toBotId: "bot-2", toBotName: "Coder", text: "Check this." }, + ]); + await message("received", peerRunId, [ + { kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." }, + ]); + + const web = await loadMessagePage(prisma, identity.threadId, undefined, 2); + expect(ids(web)).toEqual(["orphan-run", "before-receipts"]); + + const mobile = await loadMessagePage( + prisma, + identity.threadId, + undefined, + 2, + undefined, + false, + true, + ); + expect(ids(mobile)).toEqual(["sent", "received"]); + }); +}); diff --git a/apps/api/src/thread-message-pages.test.ts b/apps/api/src/thread-message-pages.test.ts index e290385..6a19d63 100644 --- a/apps/api/src/thread-message-pages.test.ts +++ b/apps/api/src/thread-message-pages.test.ts @@ -43,98 +43,6 @@ describe("thread message pages", () => { ).toBe(false); }); - it("keeps peer receipt rows when filtering peer-run output from pages", async () => { - const findMany = vi.fn(async () => [ - { - id: "message-reply", - threadId: "thread-1", - seq: 3, - role: "bot", - blocks: [{ kind: "text", text: "Echoed peer reply" }], - botId: "bot-1", - replyToMessageId: null, - runId: "run-peer", - createdAt: new Date("2026-08-16T00:00:03.000Z"), - }, - { - id: "message-received", - threadId: "thread-1", - seq: 2, - role: "user", - blocks: [ - { - kind: "bot_message_received", - fromBotId: "bot-2", - fromBotName: "Coder", - text: "Done.", - }, - ], - botId: null, - replyToMessageId: null, - runId: "run-peer", - createdAt: new Date("2026-08-16T00:00:02.000Z"), - }, - { - id: "message-user", - threadId: "thread-1", - seq: 1, - role: "bot", - blocks: [{ kind: "text", text: "Visible answer" }], - botId: "bot-1", - replyToMessageId: null, - runId: "run-user", - createdAt: new Date("2026-08-16T00:00:01.000Z"), - }, - ]); - const prisma = { - message: { findMany }, - run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) }, - } as unknown as PrismaClient; - - const page = await loadMessagePage(prisma, "thread-1", undefined, 3); - - expect(page.messages.map((message) => message.id)).toEqual([ - "message-user", - "message-received", - "message-reply", - ]); - }); - - it("filters peer-run activity when its receipt is outside the loaded page", async () => { - const findMany = vi.fn(async () => [ - { - id: "message-peer", - threadId: "thread-1", - seq: 2, - role: "bot", - blocks: [{ kind: "steps", steps: [{ label: "Echoed peer reply", count: 1 }] }], - botId: "bot-1", - replyToMessageId: null, - runId: "run-peer", - createdAt: new Date("2026-08-16T00:00:02.000Z"), - }, - { - id: "message-user", - threadId: "thread-1", - seq: 1, - role: "bot", - blocks: [{ kind: "text", text: "Visible answer" }], - botId: "bot-1", - replyToMessageId: null, - runId: "run-user", - createdAt: new Date("2026-08-16T00:00:01.000Z"), - }, - ]); - const prisma = { - message: { findMany }, - run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) }, - } as unknown as PrismaClient; - - const page = await loadMessagePage(prisma, "thread-1", undefined, 2); - - expect(page.messages.map((message) => message.id)).toEqual(["message-user"]); - }); - it("keeps peer text around-page targets and omits peer activity", async () => { const findMany = vi.fn(async () => [ { @@ -281,45 +189,6 @@ describe("thread message pages", () => { expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]); }); - it("scans past a page containing only hidden peer-run activity", async () => { - const row = (seq: number, runId: string, kind: "text" | "steps" = "text") => ({ - id: `message-${seq}`, - threadId: "thread-1", - seq, - role: "bot", - blocks: - kind === "steps" - ? [{ kind: "steps", steps: [{ label: String(seq), count: 1 }] }] - : [{ kind: "text", text: String(seq) }], - botId: "bot-1", - replyToMessageId: null, - runId, - createdAt: new Date("2026-08-16T00:00:00.000Z"), - }); - const findMany = vi - .fn() - .mockResolvedValueOnce([ - row(4, "run-peer", "steps"), - row(3, "run-peer", "steps"), - row(2, "run-peer", "steps"), - ]) - .mockResolvedValueOnce([row(1, "run-user")]); - const prisma = { - message: { findMany }, - run: { - findMany: vi - .fn() - .mockResolvedValueOnce([{ id: "run-peer" }]) - .mockResolvedValueOnce([]), - }, - } as unknown as PrismaClient; - - const page = await loadMessagePage(prisma, "thread-1", undefined, 2); - - expect(page.messages.map((message) => message.id)).toEqual(["message-1"]); - expect(findMany).toHaveBeenCalledTimes(2); - }); - it("scans past a receipt-only page so web can reach older user-visible rows", async () => { const receiptRows = [ { @@ -383,21 +252,13 @@ describe("thread message pages", () => { createdAt: new Date("2026-08-16T00:00:01.000Z"), }, ]; - const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows); - const prisma = { - message: { findMany }, - run: { - findMany: vi - .fn() - .mockResolvedValueOnce([{ id: "run-peer" }]) - .mockResolvedValueOnce([]), - }, - } as unknown as PrismaClient; + const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows); + const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient; const page = await loadMessagePage(prisma, "thread-1", undefined, 2); expect(page.messages.map((message) => message.id)).toEqual(["message-user"]); - expect(findMany).toHaveBeenCalledTimes(2); + expect(queryRaw).toHaveBeenCalledTimes(2); }); it("returns a receipt-only page when the client displays peer receipts", async () => { @@ -419,11 +280,8 @@ describe("thread message pages", () => { runId: "run-peer", createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`), }); - const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]); - const prisma = { - message: { findMany }, - run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) }, - } as unknown as PrismaClient; + const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]); + const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient; const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true); @@ -432,10 +290,10 @@ describe("thread message pages", () => { "message-receipt-3", ]); expect(page.olderCursor).toBe(2); - expect(findMany).toHaveBeenCalledTimes(1); + expect(queryRaw).toHaveBeenCalledTimes(1); }); - it("queries before the cursor and returns an ascending bounded page", async () => { + it("queries the bot messages view before the cursor as an ascending bounded page", async () => { const findMany = vi.fn(async () => [5, 4, 3].map((seq) => ({ id: `message-${seq}`, @@ -449,7 +307,7 @@ describe("thread message pages", () => { ); const prisma = { message: { findMany } } as unknown as PrismaClient; - const page = await loadMessagePage(prisma, "thread-1", 6, 2); + const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true); expect(findMany).toHaveBeenCalledWith({ where: { threadId: "thread-1", seq: { lt: 6 } }, @@ -461,7 +319,7 @@ describe("thread message pages", () => { }); it("ends pagination when the database returns no lookahead row", async () => { - const findMany = vi.fn(async () => [ + const queryRaw = vi.fn(async () => [ { id: "message-0", threadId: "thread-1", @@ -472,7 +330,7 @@ describe("thread message pages", () => { createdAt: new Date("2026-08-16T00:00:00.000Z"), }, ]); - const prisma = { message: { findMany } } as unknown as PrismaClient; + const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient; const page = await loadMessagePage(prisma, "thread-1", 1, 2); diff --git a/apps/api/src/thread-message-pages.ts b/apps/api/src/thread-message-pages.ts index cbec6ba..4d4fb20 100644 --- a/apps/api/src/thread-message-pages.ts +++ b/apps/api/src/thread-message-pages.ts @@ -1,9 +1,18 @@ import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts"; import { isPeerReceiptBlocks } from "@rakazo/core"; -import type { Prisma, PrismaClient } from "@rakazo/db"; +import type { PrismaClient } from "@rakazo/db"; +import { Prisma } from "@rakazo/db"; type MessageDb = PrismaClient | Prisma.TransactionClient; +/** Peer-run rows that stay in the normal transcript: receipts, ask cards and the bot's own reply. */ +const PEER_VISIBLE_BLOCK_KINDS: readonly string[] = [ + "bot_message_sent", + "bot_message_received", + "ask", + "text", +]; + export async function loadMessagePage( prisma: MessageDb, threadId: string, @@ -47,37 +56,69 @@ export async function loadMessagePage( } } - let cursor = before; - while (true) { + if (includePeerRuns) { const rows = await prisma.message.findMany({ where: { threadId, - ...(cursor === undefined ? {} : { seq: { lt: cursor } }), + ...(before === undefined ? {} : { seq: { lt: before } }), }, orderBy: { seq: "desc" }, take: pageSize + 1, }); - const hasOlder = rows.length > pageSize; - const pageRows = rows.slice(0, pageSize).reverse(); - const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows); + return toPage(threadId, rows, pageSize); + } + + let cursor = before; + while (true) { + const rows = await visibleMessageRows(prisma, threadId, cursor, pageSize + 1); + const page = toPage(threadId, rows, pageSize); // Web hides receipts client-side, so its receipt-only pages keep scanning. // Mobile explicitly retains them and must receive each page for pagination. - const hasSubstantive = visibleRows.some( - (row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]), - ); - if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) { - return { - threadId, - messages: visibleRows.map(toThreadMessage), - olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null, - }; - } - // TODO: only rescan when a raw page is entirely peer output. Consider a run relation if - // long peer-only histories make this path hot. - cursor = pageRows[0]?.seq; + const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks)); + if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page; + cursor = page.olderCursor; } } +type MessageRow = Parameters[0]; + +function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage { + const pageRows = rows.slice(0, pageSize).reverse(); + return { + threadId, + messages: pageRows.map(toThreadMessage), + olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null, + }; +} + +/** + * Newest-first transcript rows with peer-run activity excluded in the database, so a long + * peer-only stretch costs one query instead of a rescan per hidden page. + */ +function visibleMessageRows( + prisma: MessageDb, + threadId: string, + before: number | undefined, + limit: number, +): Promise { + return prisma.$queryRaw` + SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId", + m."replyQuote", m."runId", m."createdAt" + FROM messages m + LEFT JOIN runs r ON r.id = m."runId" + WHERE m."threadId" = ${threadId} + ${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`} + AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join( + PEER_VISIBLE_BLOCK_KINDS.map( + (kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`, + ), + " OR ", + )}) + ORDER BY m.seq DESC + LIMIT ${limit} + `; +} + export async function loadAllMessages( prisma: PrismaClient, threadId: string, @@ -106,15 +147,8 @@ async function withoutPeerRunMessages run.id)); return rows.filter((row) => { if (!row.runId || !peerRunIds.has(row.runId)) return true; - // Keep peer receipts (chips), ask cards, and the bot's own text reply. const blocks = row.blocks as MessageBlock[]; - return blocks.some( - (block) => - block.kind === "bot_message_sent" || - block.kind === "bot_message_received" || - block.kind === "ask" || - block.kind === "text", - ); + return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind)); }); } @@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: { !!block && typeof block === "object" && "kind" in block && - (block.kind === "bot_message_received" || - block.kind === "bot_message_sent" || - block.kind === "ask" || - block.kind === "text"), + typeof block.kind === "string" && + PEER_VISIBLE_BLOCK_KINDS.includes(block.kind), ) ); } diff --git a/apps/api/src/thread-target.test.ts b/apps/api/src/thread-target.test.ts index 8658532..199eec9 100644 --- a/apps/api/src/thread-target.test.ts +++ b/apps/api/src/thread-target.test.ts @@ -183,7 +183,7 @@ describe("threadSnapshot", () => { }, ]); const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue({ seq: 4 }), @@ -203,7 +203,10 @@ describe("threadSnapshot", () => { const snapshot = await threadSnapshot({ prisma }, target); - expect(tx.$queryRaw).toHaveBeenCalledOnce(); + expect(tx.$queryRaw).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringContaining("FOR SHARE")]), + "thread-1", + ); expect(findManyEvents).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ @@ -245,7 +248,7 @@ describe("threadSnapshot", () => { const findManyEvents = vi.fn(); const findFirstRun = botRunFindFirst([run]); const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), @@ -330,7 +333,7 @@ describe("threadSnapshot", () => { prisma: { $transaction: vi.fn(async (callback: (client: unknown) => unknown) => callback({ - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), @@ -385,7 +388,7 @@ describe("threadSnapshot", () => { }; const findFirstRun = botRunFindFirst([failed, completed]); const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), @@ -421,7 +424,7 @@ describe("threadSnapshot", () => { const findManyEvents = vi.fn(); const findFirstRun = botRunFindFirst([]); const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), @@ -925,9 +928,16 @@ function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] }) ); } +/** The snapshot's thread lock finds the thread; its message page query finds no rows. */ +function threadQueries() { + return vi.fn(async (sql: TemplateStringsArray) => + sql.join("").includes("FOR SHARE") ? [{ id: "thread-1" }] : [], + ); +} + function groupPrisma(findManyRuns: ReturnType) { const tx = { - $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + $queryRaw: threadQueries(), message: { findMany: vi.fn().mockResolvedValue([]) }, event: { findFirst: vi.fn().mockResolvedValue(null), diff --git a/apps/api/src/user-profile.ts b/apps/api/src/user-profile.ts index 4307f1a..5192c06 100644 --- a/apps/api/src/user-profile.ts +++ b/apps/api/src/user-profile.ts @@ -1,4 +1,6 @@ import { ORPCError } from "@orpc/server"; +import type { CoachRhythmDeps } from "@rakazo/adapters"; +import { syncCoachTimeZone } from "@rakazo/adapters"; import type { Actor, ForgetUserFacts, @@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise { try { - await updateUserProfile(prisma, actor.userId, input); + await updateUserProfile(deps.prisma, actor.userId, input); } catch (error) { throw toRpcError(error); } - return getUserProfileState(prisma, actor.userId); + if (input.timeZone !== undefined) await syncCoachTimeZone(deps, actor.userId); + return getUserProfileState(deps.prisma, actor.userId); } /** Unknown ids are ignored, so a caller cannot probe another user's facts. */ export async function forgetProfile( - prisma: PrismaClient, + deps: CoachRhythmDeps, actor: Actor, input: ForgetUserFacts, ): Promise { - await forgetUserFacts( - prisma, - actor.userId, - input.factIds - ? { factIds: input.factIds } - : input.category - ? { category: input.category } - : { all: true }, - ); - return getUserProfileState(prisma, actor.userId); + const target = input.factIds + ? { factIds: input.factIds } + : input.category + ? { category: input.category } + : { all: true as const }; + await forgetUserFacts(deps.prisma, actor.userId, target); + // Forgetting everything clears the time zone too. + if ("all" in target) await syncCoachTimeZone(deps, actor.userId); + return getUserProfileState(deps.prisma, actor.userId); } diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 47d547b..4fc995a 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -1903,6 +1903,32 @@ describe("mobile thread event reduction", () => { ]); }); + it("reads a choice card as its question and answer", () => { + const card = { + kind: "choice" as const, + step: "goal" as const, + question: "What’s one thing you want to get done in the next few weeks?", + textInput: { placeholder: "Your goal" }, + options: [{ id: "skip", letter: "", label: "Skip" }], + }; + expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question); + expect( + blockText( + mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]), + ), + ).toBe(`${card.question}\nRun a 10K`); + expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe( + `${card.question}\nSkip`, + ); + const note = { + kind: "intake_note" as const, + note: "done" as const, + nextCheckInAt: null, + text: "All set. I’m here whenever you need me.", + }; + expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text); + }); + it("deduplicates durable messages and replaces matching transient subagent state", () => { const initial = snapshot([ mobileMessage("message-1", [{ kind: "text", text: "old" }]), diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 18ce919..50f3ccd 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -894,6 +894,12 @@ export function blockText(message: MobileMessage) { if (block.kind === "child_bot") { return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`; } + // Choice cards are answered on the web; here they read as the question and its answer. + if (block.kind === "choice") { + const answer = + block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label; + return answer ? `${block.question}\n${answer}` : block.question; + } if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`; if (block.kind === "file") { diff --git a/apps/web/e2e/chart-links.spec.ts b/apps/web/e2e/chart-links.spec.ts index c6e1133..81f6950 100644 --- a/apps/web/e2e/chart-links.spec.ts +++ b/apps/web/e2e/chart-links.spec.ts @@ -4,6 +4,10 @@ import { captureScreenshot } from "./helpers"; const fixture = "/e2e/fixtures/chart-links.html"; test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => { + test.setTimeout(120_000); + // ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below. + await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`); + await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 }); for (const inspect of ["0", "1"]) { for (const href of [ 'javascript:void(document.body.dataset.plotExecuted="yes")', diff --git a/apps/web/e2e/chat-contrast.spec.ts b/apps/web/e2e/chat-contrast.spec.ts index 2ea5310..acadca2 100644 --- a/apps/web/e2e/chat-contrast.spec.ts +++ b/apps/web/e2e/chat-contrast.spec.ts @@ -255,7 +255,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t await sidebar.getByRole("button", { name: "Today", exact: true }).click(); const heute = page.getByTestId("heute-brief"); - await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible(); + await expect(heute).toBeVisible(); await inEachTheme(page, async (theme) => { await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`); await expectReadable(heute, `${theme}: the Heute brief`, { inside: true }); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 84d7794..edc646d 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) { } /** - * Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation` - * is set, it then opens the main bot's own thread, where most specs start. + * Walks the first-run wizard, which lands in the main conversation with the main bot's first + * onboarding question pending (the composer works regardless). Unless `mainConversation` is + * set, it then opens the main bot's own thread, where most specs start. */ export async function completeOnboarding( page: Page, @@ -101,13 +102,12 @@ export async function completeOnboarding( if (page.url().includes("/onboarding")) { await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations|scan|bot)$/, + /^(emoji|language|voice|integrations|scan|bot)$/, { timeout: 30_000 }, ); } const design = page.getByRole("heading", { name: /Design your Oo/i }); - const preferences = page.getByRole("heading", { name: /Tell us about yourself/i }); const language = page.getByRole("heading", { name: /Choose your language/i }); const voice = page.getByRole("heading", { name: /Pick a voice/i }); const integrations = page.getByRole("heading", { name: "Server integrations", exact: true }); @@ -119,8 +119,6 @@ export async function completeOnboarding( await expect(entry.first()).toBeVisible({ timeout: 20_000 }); if (await design.isVisible()) { - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(preferences).toBeVisible(); await page.getByRole("button", { name: "Continue", exact: true }).click(); await expect(language).toBeVisible(); await page.getByRole("button", { name: /Skip/i }).click(); @@ -133,8 +131,15 @@ export async function completeOnboarding( await page.getByRole("button", { name: "Skip", exact: true }).click(); } - // Scan step auto-advances to the main bot, then onboarding lands in the main conversation. - await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); + // The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding + // then lands in the main conversation. + const skipScan = page + .locator('[data-onboarding-step="scan"]') + .getByRole("button", { name: "Skip", exact: true }); + await expect(async () => { + if (await skipScan.isVisible()) await skipScan.click(); + expect(page.url()).toMatch(/\/app\/g\/[^/]+$/); + }).toPass({ timeout: 30_000 }); await expect(chief).toBeVisible({ timeout: 15_000 }); await waitForChatComposer(page); @@ -242,6 +247,7 @@ export async function openUserSettings( | "memory" | "profile" | "voice" + | "notifications" | "usage" | "computer" | "updates", diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index 6347490..293dcc9 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -1,20 +1,136 @@ +import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; -import { captureScreenshot, completeOnboarding, signup } from "./helpers"; +import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers"; + +/** The nav button reads "Today" or "Today, N need you". */ +function openHeute(page: Page) { + return page.getByRole("button", { name: /^Today(,|$)/ }).click(); +} + +function shiftDate(date: string, days: number) { + return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); +} test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await completeOnboarding(page); - await page.getByRole("button", { name: "Today", exact: true }).click(); + await openHeute(page); await expect(page).toHaveURL(/\/app\/[^/]+$/); const brief = page.getByTestId("heute-brief"); await expect(brief).toBeVisible(); await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0); await expect(page.getByText("evOo Mission Control")).toHaveCount(0); + await expect(page.getByTestId("heute-mission-control-link")).toHaveCount(0); await expect(page.getByTestId("heute-status")).toBeVisible(); - await expect(page.getByTestId("heute-mission-control-link")).toBeVisible(); + // Nothing tracked yet: no coach sections. + await expect(page.getByTestId("heute-coach-brief")).toHaveCount(0); + await expect(page.getByTestId("heute-habits")).toHaveCount(0); await captureScreenshot(page, testInfo, "heute-native-brief"); }); + +test("Heute shows the coach's brief, habits and goals, checks habits off and sets check-ins", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `heute-coach-${stamp}@rakazo.test`, "password12", "Heute Coach"); + await completeOnboarding(page); + + // The main bot writes today's brief through its tool. + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("write today's brief: Two calm hours on the beta, then a walk."); + await page.keyboard.press("Enter"); + await expect(page.getByText("today's brief is ready.").first()).toBeVisible({ timeout: 30_000 }); + + const { date } = await rpc(page, "coach/today", {}); + const goal = await rpc(page, "coach/goals/create", { + title: "Ship the beta", + targetDate: shiftDate(date, 30), + }); + const habit = await rpc(page, "coach/habits/create", { title: "Walk 20 minutes" }); + for (const day of [1, 2]) { + await rpc(page, "coach/checkIns/log", { + kind: "habit", + habitId: habit.id, + date: shiftDate(date, -day), + done: true, + }); + } + + await openHeute(page); + await expect(page.getByTestId("heute-coach-brief")).toHaveText( + "Two calm hours on the beta, then a walk.", + ); + await expect(page.getByTestId(`heute-goal-${goal.id}`)).toContainText("Ship the beta"); + const habitRow = page.getByTestId(`heute-habit-${habit.id}`); + await expect(habitRow).toContainText("Walk 20 minutes"); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("2 days"); + + const checkbox = habitRow.getByRole("checkbox"); + await expect(checkbox).not.toBeChecked(); + const logged = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/checkIns/log") && response.ok(), + ); + await checkbox.click(); + await logged; + await expect(checkbox).toBeChecked(); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days"); + await captureScreenshot(page, testInfo, "heute-coach"); + + await page.reload(); + await openHeute(page); + await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked(); + + // Check-in times live behind one compact control. + await page.getByTestId("heute-rhythm").click(); + const panel = page.getByTestId("heute-rhythm-panel"); + await expect(panel).toBeVisible(); + const morning = panel.getByTestId("heute-rhythm-morning"); + await expect(morning.getByRole("switch")).not.toBeChecked(); + const enabled = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await morning.getByRole("switch").click(); + await enabled; + const time = morning.locator('input[type="time"]'); + await expect(time).toHaveValue("08:00"); + const moved = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await time.fill("07:30"); + await moved; + await expect(time).toHaveValue("07:30"); + const weekly = panel.getByTestId("heute-rhythm-weekly"); + const weeklyOn = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await weekly.getByRole("switch").click(); + await weeklyOn; + await expect(weekly.getByRole("combobox")).toHaveValue("0"); + await captureScreenshot(page, testInfo, "heute-rhythm"); + + expect(await rpc(page, "coach/rhythm/get", {})).toEqual({ + morning: "07:30", + evening: null, + weekly: { day: 0, time: "18:00" }, + }); + const main = await rpc<{ id: string }>(page, "bots/ensureMain", {}); + const routines = await rpc(page, "routines/list", { botId: main.id }); + expect(routines.map(({ name, coachKey }) => ({ name, coachKey }))).toEqual( + expect.arrayContaining([ + { name: "Morning brief", coachKey: "morning" }, + { name: "Weekly review", coachKey: "weekly" }, + ]), + ); + expect(routines).toHaveLength(2); + + // The main bot's routine list names the check-ins by their key, in the UI language. + await openMainBotThread(page); + await page.getByTitle("Agent computer").click(); + await expect(page.getByText("Morning brief", { exact: true })).toBeVisible(); + await expect(page.getByText("Weekly review", { exact: true })).toBeVisible(); +}); diff --git a/apps/web/e2e/inbox.spec.ts b/apps/web/e2e/inbox.spec.ts index b31ea48..683942c 100644 --- a/apps/web/e2e/inbox.spec.ts +++ b/apps/web/e2e/inbox.spec.ts @@ -39,6 +39,75 @@ test("answers a specialist's question from the main conversation", async ({ page await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); }); +test("answers a question from another space in the main conversation", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross"); + await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 }); + const helper = await createBot(page, "Helper"); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", { + name: "Scout", + title: "", + description: "", + notifyOnFinish: true, + computerMode: "team", + }); + await rpcInSpace(page, side.id, "threads/send", { + botId: scout.id, + text: "ask me which city to use", + }); + const runInSide = async () => + ( + await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", { + botId: scout.id, + }) + ).run ?? null; + await expect + .poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 }) + .toBe("waiting_input"); + + await openMainConversation(page, helper.id); + const inbox = page.getByRole("region", { name: "Needs you" }); + const ask = inbox.locator("[data-kind='ask']"); + await expect(ask).toContainText("Scout", { timeout: 30_000 }); + await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await captureScreenshot(page, testInfo, "inbox-cross-space"); + + await ask.getByRole("button", { name: "Edit first" }).click(); + await ask.getByRole("textbox", { name: "Answer" }).fill("Paris"); + await ask.getByRole("button", { name: "Send answer" }).click(); + await expect(ask).toHaveCount(0); + await expect.poll(runInSide, { timeout: 60_000 }).toBeNull(); + + // The reply arrives as unread from the other space and opens there. + const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" }); + await expect(reply).toContainText("Paris", { timeout: 45_000 }); + await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await reply.getByRole("button").click(); + await page.waitForURL(new RegExp(`/app/${scout.id}$`)); + await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); + expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id); +}); + +async function rpcInSpace( + page: Page, + spaceId: string, + procedure: string, + body: unknown, +): Promise { + const response = await page.request.post(`/rpc/${procedure}`, { + data: { json: body }, + headers: { "x-rakazo-space-id": spaceId }, + }); + const parsed = (await response.json()) as { json?: T; error?: { message?: string } }; + if (!response.ok() || parsed.error) { + throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`); + } + return parsed.json as T; +} + async function createBot(page: Page, name: string) { return rpc<{ id: string }>(page, "bots/create", { name, diff --git a/apps/web/e2e/knowledge-panel.spec.ts b/apps/web/e2e/knowledge-panel.spec.ts index a504c9d..3ab2d19 100644 --- a/apps/web/e2e/knowledge-panel.spec.ts +++ b/apps/web/e2e/knowledge-panel.spec.ts @@ -114,6 +114,36 @@ test("memory and skills are readable and editable in the app", async ({ page }, await expect(botDocEditor).toHaveValue(new RegExp(botMarker)); await botMemory.getByRole("button", { name: "Cancel", exact: true }).click(); + // Remembering appends a dated note and keeps the edit above. + await rpc(page, "threads/send", { + botId: activeBotId(page), + text: `remember that the tester ${stamp} prefers tea`, + }); + await expect + .poll( + async () => + ( + await rpc(page, "memory/list", { + botId: activeBotId(page), + scope: "bot", + }) + ).find((doc) => doc.path === "MEMORY.md")?.content, + { timeout: 60_000 }, + ) + .toMatch( + new RegExp( + `^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`, + ), + ); + await expect + .poll( + async () => + (await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ?? + null, + { timeout: 60_000 }, + ) + .toBeNull(); + // Skills: create one through the editor, reopen it, edit, then delete it. // Builtin catalog is currently empty; user skills still cover create/edit/delete. await knowledge.getByRole("tab", { name: "Skills", exact: true }).click(); diff --git a/apps/web/e2e/message-hover-actions.spec.ts b/apps/web/e2e/message-hover-actions.spec.ts index 4667825..ab6d797 100644 --- a/apps/web/e2e/message-hover-actions.spec.ts +++ b/apps/web/e2e/message-hover-actions.spec.ts @@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr /** Submit from the Send control so an in-flight prior send cannot swallow Enter. */ async function sendComposerMessage(page: Page, composer: Locator, text: string) { await composer.fill(text); - const send = page.getByRole("button", { name: "Send", exact: true }); + // An open onboarding card in the transcript has its own Send. + const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true }); await expect(send).toBeEnabled(); await send.click(); await expect(composer).toHaveValue(""); @@ -72,14 +73,25 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( const transcript = page.getByTestId("transcript"); - // Empty start: no Fresh-start greeting. Answer the focus card so a plain - // bot text bubble exists for hover layout checks. - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({ + // Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot + // row's rail sits at the row's end, so the measured reply must wrap and fill its row. + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible({ timeout: 20_000, }); await page.getByTestId("focus-choice-day").click(); - const botText = page.getByText(/Got it\./); - await expect(botText).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({ + timeout: 20_000, + }); + const composer = page.getByRole("combobox", { name: /^Message/ }); + await sendComposerMessage( + page, + composer, + `hover-bot-${stamp} ${"please look into this ".repeat(7)}`, + ); + const botText = page.getByText(/i handled: hover-bot-/); + await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 }); const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first(); await expect(botRow).toBeVisible(); await expectRailAtRest(page, botRow); @@ -121,7 +133,6 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( const parentText = `hover-parent-${stamp}`; const replyText = `hover-reply-${stamp}`; - const composer = page.getByRole("combobox", { name: /^Message/ }); await expect(composer).toBeVisible(); await sendComposerMessage(page, composer, parentText); @@ -387,12 +398,19 @@ test.describe("touch message actions", () => { expect( await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches), ).toBe(false); - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({ + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible({ timeout: 20_000, }); await page.getByTestId("focus-choice-day").click(); - const botText = page.getByText(/Got it\./); - await expect(botText).toBeVisible({ timeout: 20_000 }); + // Onboarding writes no text of its own, so ask for a plain reply to act on. + const composer = page.getByRole("combobox", { name: /^Message/ }); + await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`); + const botText = page.getByText(/i handled: touch-bot-/); + await expect(page.getByTestId("transcript").getByText(/i handled: touch-bot-/)).toBeVisible({ + timeout: 20_000, + }); const row = page .getByTestId("transcript") .locator("[data-message-id]") diff --git a/apps/web/e2e/new-bot-ux.spec.ts b/apps/web/e2e/new-bot-ux.spec.ts index 68bc135..35645fb 100644 --- a/apps/web/e2e/new-bot-ux.spec.ts +++ b/apps/web/e2e/new-bot-ux.spec.ts @@ -42,7 +42,9 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses", await page.waitForURL(/\/app\/[^/]+$/); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed"); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); await expect( page.getByTestId("transcript").getByText("Start the conversation — New Bot is on stage above."), ).toBeVisible(); @@ -117,20 +119,30 @@ test("later bot waits before showing the focus card; sending cancels it", async await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay"); await completeOnboarding(page, undefined, { mainConversation: true }); // The main bot shows the focus card immediately in the main conversation. - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible(); await page.clock.install(); await createBotFromPicker(page); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); await page.clock.fastForward(9_000); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); await page.clock.fastForward(1_500); - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible(); await createBotFromPicker(page, { name: "Later Bot" }); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); const composer = page.getByPlaceholder(/Message/); await composer.fill("I'll set this up myself"); // Send must finish before the delay is advanced: cancel runs after a successful @@ -144,7 +156,9 @@ test("later bot waits before showing the focus card; sending cancels it", async page.getByTestId("transcript").getByText("I'll set this up myself").first(), ).toBeVisible(); await page.clock.fastForward(12_000); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); }); test("plus picker can create a Private computer bot", async ({ page }, testInfo) => { diff --git a/apps/web/e2e/notification-settings.spec.ts b/apps/web/e2e/notification-settings.spec.ts new file mode 100644 index 0000000..b21a36c --- /dev/null +++ b/apps/web/e2e/notification-settings.spec.ts @@ -0,0 +1,130 @@ +/** + * E2E: Settings → Notifications turns push on for this browser (the Push API is stubbed: headless + * Chromium has no push service), sets quiet hours and switches nudges off; all of it survives a + * reload. + */ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; + +const ENDPOINT = "https://push.example.test/e2e-device"; + +/** + * A fake Notification permission and service worker registration, kept in sessionStorage so they + * survive a reload. Headless Chromium reports notifications as denied even when granted. + */ +async function stubPushApi(page: Page, endpoint: string) { + await page.addInitScript((pushEndpoint) => { + const permissionKey = "e2e:notification-permission"; + const notification = Object.defineProperties(function Notification() {}, { + permission: { get: () => sessionStorage.getItem(permissionKey) ?? "default" }, + requestPermission: { + value: async () => { + sessionStorage.setItem(permissionKey, "granted"); + return "granted"; + }, + }, + }); + Object.defineProperty(window, "Notification", { + configurable: true, + writable: true, + value: notification, + }); + const storageKey = "e2e:push-subscription"; + const current = () => { + const stored = sessionStorage.getItem(storageKey); + if (!stored) return null; + const saved = JSON.parse(stored) as { serverKey: number[] }; + return { + endpoint: pushEndpoint, + options: { applicationServerKey: new Uint8Array(saved.serverKey).buffer }, + toJSON: () => ({ + endpoint: pushEndpoint, + keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }, + }), + unsubscribe: async () => { + sessionStorage.removeItem(storageKey); + return true; + }, + }; + }; + const registration = { + scope: `${location.origin}/`, + pushManager: { + getSubscription: async () => current(), + subscribe: async (options: { applicationServerKey: Uint8Array }) => { + sessionStorage.setItem( + storageKey, + JSON.stringify({ serverKey: [...new Uint8Array(options.applicationServerKey)] }), + ); + return current(); + }, + }, + }; + Object.defineProperty(navigator, "serviceWorker", { + configurable: true, + value: { + register: async () => registration, + getRegistration: async () => registration, + ready: Promise.resolve(registration), + }, + }); + if (!("PushManager" in window)) { + Object.defineProperty(window, "PushManager", { configurable: true, value: class {} }); + } + }, endpoint); +} + +test("notification settings: push on this device, quiet hours and nudges", async ({ + page, +}, testInfo) => { + await stubPushApi(page, ENDPOINT); + await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester"); + await completeOnboarding(page); + + let settings = await openUserSettings(page, "notifications"); + const panel = settings.getByTestId("notification-settings"); + const push = panel.getByTestId("notification-push-switch"); + await expect(push).not.toBeChecked(); + + const subscribed = page.waitForRequest("**/rpc/notifications/webPush/subscribe"); + await push.click(); + expect((await subscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } }); + await expect(push).toBeChecked(); + + const quiet = panel.getByTestId("notification-quiet-hours-switch"); + await expect(quiet).not.toBeChecked(); + await quiet.click(); + await expect(quiet).toBeChecked(); + const start = panel.getByTestId("notification-quiet-hours-start"); + await expect(start).toHaveValue("22:00"); + await expect(panel.getByTestId("notification-quiet-hours-end")).toHaveValue("07:00"); + const saved = page.waitForResponse( + (response) => + response.url().includes("/rpc/notifications/settings/update") && + response.request().postData()?.includes("23:30") === true, + ); + await start.fill("23:30"); + await saved; + + const nudges = panel.getByTestId("notification-nudges-switch"); + await expect(nudges).toBeChecked(); + await nudges.click(); + await expect(nudges).not.toBeChecked(); + await expect + .poll(() => rpc<{ nudges: boolean }>(page, "notifications/settings/get", null)) + .toMatchObject({ nudges: false, quietHours: { start: "23:30", end: "07:00" } }); + + await captureScreenshot(page, testInfo, "notification-settings"); + + await page.reload(); + settings = await openUserSettings(page, "notifications"); + await expect(settings.getByTestId("notification-push-switch")).toBeChecked(); + await expect(settings.getByTestId("notification-quiet-hours-start")).toHaveValue("23:30"); + await expect(settings.getByTestId("notification-nudges-switch")).not.toBeChecked(); + + const unsubscribed = page.waitForRequest("**/rpc/notifications/webPush/unsubscribe"); + await settings.getByTestId("notification-push-switch").click(); + expect((await unsubscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } }); + await expect(settings.getByTestId("notification-push-switch")).not.toBeChecked(); +}); diff --git a/apps/web/e2e/onboarding-conversation.spec.ts b/apps/web/e2e/onboarding-conversation.spec.ts index 57bd3bb..7809caa 100644 --- a/apps/web/e2e/onboarding-conversation.spec.ts +++ b/apps/web/e2e/onboarding-conversation.spec.ts @@ -16,11 +16,16 @@ test("focus choice suggests apps and preserves a completed connection", async ({ await expect( page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."), ).toHaveCount(0); - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible(); await expect(page.getByTestId("focus-choice-day")).toBeVisible(); await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work"); await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing"); await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew"); + // Onboarding questions offer a quiet way out instead of a dismiss button. + await expect(page.getByTestId("focus-choice-skip")).toBeVisible(); + await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "01-focus-choice"); await captureScreenshot(page, testInfo, "choice-card-onboarding"); @@ -33,6 +38,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({ ).toBeVisible(); expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief"); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); + // The app cards speak for themselves; onboarding adds no text around them. + await expect(page.getByText(/^Got it/)).toHaveCount(0); await expect(page.getByText("Slack", { exact: true })).toBeVisible(); await expect(page.getByText("Gmail", { exact: true })).toBeVisible(); const connectionCards = page.getByRole("group", { name: / connection$/ }); @@ -45,10 +52,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({ ); expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8); expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8); - await page - .getByTestId("transcript") - .getByText("Hit those three and I’ll start pulling the picture.") - .scrollIntoViewIfNeeded(); + // The next onboarding question follows the suggestions. + const goal = page.locator('[data-testid="choice-card"][data-step="goal"]'); + await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?"); + await slackCard(page).scrollIntoViewIfNeeded(); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "02-app-suggestions"); const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" }); diff --git a/apps/web/e2e/onboarding-discovery.spec.ts b/apps/web/e2e/onboarding-discovery.spec.ts new file mode 100644 index 0000000..d953085 --- /dev/null +++ b/apps/web/e2e/onboarding-discovery.spec.ts @@ -0,0 +1,77 @@ +/** + * E2E: the deployment owner's onboarding asks before scanning the fixture home folder the + * harness configures, then adds only the projects and facts the user picks. + */ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts"; +import { captureScreenshot, rpc, signup } from "./helpers"; + +const api = process.env.API_URL ?? "http://127.0.0.1:3110"; + +/** Advances whichever wizard step is showing until the scan step is reached. */ +async function walkToScanStep(page: Page) { + const surface = page.locator('[data-rakazo-surface="onboarding"]'); + await expect(async () => { + if ((await surface.getAttribute("data-onboarding-step")) === "scan") return; + const skip = page.getByRole("button", { name: /^Skip/ }).first(); + const next = (await skip.isVisible()) + ? skip + : page.getByRole("button", { name: "Continue", exact: true }); + await next.click({ timeout: 2_000 }); + await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 }); + }).toPass({ timeout: 60_000 }); +} + +test("asks before looking for projects and adds only what the user picks", async ({ + page, +}, testInfo) => { + const email = `discovery-${Date.now()}@rakazo.test`; + await signup(page, email, "password12", "Discovery"); + await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); + // The harness starts without a deployment owner; this spec takes the seat for its run. + const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } }); + expect(owner.ok()).toBe(true); + const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null }; + try { + await page.goto("/onboarding", { waitUntil: "domcontentloaded" }); + await walkToScanStep(page); + + await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible(); + await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible(); + await captureScreenshot(page, testInfo, "onboarding-discovery-consent"); + await page.getByRole("button", { name: "Look", exact: true }).click(); + + const projects = page.getByRole("group", { name: "Add as projects" }); + await expect(projects.getByRole("checkbox")).toHaveCount(2); + const atlas = projects.getByRole("checkbox", { name: /atlas/ }); + await expect(atlas).not.toBeChecked(); + await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked(); + await atlas.check(); + const facts = page.getByRole("group", { name: "Add to About you" }); + await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked(); + await facts.getByRole("checkbox", { name: "Python" }).uncheck(); + await captureScreenshot(page, testInfo, "onboarding-discovery-results"); + await page.getByRole("button", { name: "Add", exact: true }).click(); + await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); + + const navigation = await rpc(page, "spaces/list", {}); + const names = navigation.spaces.map((space) => space.name); + expect(names).toContain("atlas"); + expect(names).not.toContain("garden"); + expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch( + /code\/atlas$/, + ); + const values = (await rpc(page, "profile/get", {})).facts.map( + (fact) => fact.value, + ); + expect(values).toEqual( + expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]), + ); + expect(values).not.toContain("Codes in Python"); + } finally { + await page.request.post(`${api}/__e2e/deployment-owner`, { + data: { userId: previousOwnerUserId }, + }); + } +}); diff --git a/apps/web/e2e/onboarding-emoji-first.spec.ts b/apps/web/e2e/onboarding-emoji-first.spec.ts index 0a49936..51e1bf8 100644 --- a/apps/web/e2e/onboarding-emoji-first.spec.ts +++ b/apps/web/e2e/onboarding-emoji-first.spec.ts @@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => { }); /** - * E2E test for the new emoji-first onboarding flow: + * E2E test for the emoji-first onboarding flow: * 1. Sign up → lands on onboarding * 2. Design Oo: pick shape + color → live preview - * 3. Tell us about yourself: name, timezone, hobbies, work style, communication - * 4. Choose language - * 5. Skip integrations → bot created → land in chat + * 3. Choose language, skip the voice + * 4. Skip integrations → bot created → land in chat, where the main bot asks the rest */ test("emoji-first onboarding: shape + color selection with live preview", async ({ @@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview"); - // Click Continue → goes to preferences step + // Click Continue → goes to the language step await page .getByRole("button", { name: /Continue/i }) .first() .click(); - // Step 2: Tell us about yourself - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - - if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences"); - - // Fill in name - await page.getByPlaceholder(/Your name/i).fill("Alice"); - - // Fill in timezone - await page.getByLabel(/Timezone/i).fill("Europe/Vienna"); - - // Select a hobby - const hobbyButtons = page.locator("button[aria-pressed]"); - if (await hobbyButtons.count()) { - await hobbyButtons.first().click(); // toggle first hobby - } - - // Select work style - const workStyleButtons = page.locator("button[aria-pressed]:visible"); - if ((await workStyleButtons.count()) >= 2) { - await workStyleButtons.nth(0).click(); - } - - // Select communication style - const commButtons = page.locator("button[aria-pressed]:visible"); - if ((await commButtons.count()) >= 2) { - await commButtons.nth(0).click(); - } - - if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled"); - - // Click Continue → goes to language step - await page - .getByRole("button", { name: /Continue/i }) - .first() - .click(); - - // Step 3: Language selection + // Step 2: Language selection await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); if (testInfo) await captureScreenshot(page, testInfo, "05-language-step"); @@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => { .first() .click(); - // Preferences step — just continue - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - await page - .getByRole("button", { name: /Continue/i }) - .first() - .click(); - // Language step — click Skip, then skip voice. await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await page.getByRole("button", { name: /Skip/i }).click(); diff --git a/apps/web/e2e/onboarding-intake.spec.ts b/apps/web/e2e/onboarding-intake.spec.ts new file mode 100644 index 0000000..7e223cf --- /dev/null +++ b/apps/web/e2e/onboarding-intake.spec.ts @@ -0,0 +1,91 @@ +/** + * E2E: after the wizard the main bot gets to know the user in the main conversation, one card + * after each answer, without ever blocking the composer. + */ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +function card(page: Page, step: string) { + return page.locator(`[data-testid="choice-card"][data-step="${step}"]`); +} + +test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({ + page, +}, testInfo) => { + await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester"); + await completeOnboarding(page, undefined, { mainConversation: true }); + + await page.getByTestId("focus-choice-day").click(); + const goal = card(page, "goal"); + await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?"); + + // Typing instead of answering goes to the main bot, and the card stays answerable. + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("Quick question first"); + await composer.press("Enter"); + await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, { + timeout: 60_000, + }); + const goalInput = goal.getByRole("textbox", { + name: "What’s one thing you want to get done in the next few weeks?", + }); + await goalInput.fill("Run a 10K"); + await goal.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-goal"); + await goalInput.press("Enter"); + await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K"); + + const rhythm = card(page, "rhythm"); + await expect(rhythm).toContainText("When should I check in with you?"); + // The sidebar previews the main conversation as the web words it: the pending question. + const mainEntry = page.locator("aside").first().locator("[data-main-conversation]"); + await expect(mainEntry).toContainText("When should I check in with you?"); + await rhythm.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-rhythm"); + await rhythm.getByTestId("rhythm-choice-morning").click(); + await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning"); + + const quiet = card(page, "quiet_hours"); + await expect(quiet).toContainText("When should I stay quiet?"); + await quiet.getByTestId("quiet_hours-choice-22-07").click(); + + // Only a browser that can still turn push on is asked about notifications. + // The web words the closing note itself, from the time of the first check-in. + const closing = page + .getByTestId("transcript") + .getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/); + const notifications = card(page, "notifications"); + await expect(notifications.or(closing).first()).toBeVisible(); + if (await notifications.isVisible()) { + await notifications.getByTestId("notifications-choice-later").click(); + } + await expect(closing).toBeVisible(); + await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./); + await expect( + page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'), + ).toHaveCount(0); + await closing.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-done"); + + const settings = await rpc<{ quietHours: { start: string; end: string } | null }>( + page, + "notifications/settings/get", + null, + ); + expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" }); + const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>( + page, + "profile/get", + null, + ); + expect(profile.facts).toContainEqual( + expect.objectContaining({ + value: "Wants help with day-to-day work first", + source: "onboarding", + }), + ); +}); diff --git a/apps/web/e2e/onboarding-model-required.spec.ts b/apps/web/e2e/onboarding-model-required.spec.ts index bfd722e..224608d 100644 --- a/apps/web/e2e/onboarding-model-required.spec.ts +++ b/apps/web/e2e/onboarding-model-required.spec.ts @@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({ await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations)$/, + /^(emoji|language|voice|integrations)$/, { timeout: 30_000 }, ); await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ @@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) { await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations)$/, + /^(emoji|language|voice|integrations)$/, { timeout: 30_000 }, ); await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ diff --git a/apps/web/e2e/onboarding-oo-swipe.spec.ts b/apps/web/e2e/onboarding-oo-swipe.spec.ts index 8e175f2..0789ace 100644 --- a/apps/web/e2e/onboarding-oo-swipe.spec.ts +++ b/apps/web/e2e/onboarding-oo-swipe.spec.ts @@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page } .getByRole("button", { name: /Continue/i }) .first() .click(); - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); }); diff --git a/apps/web/e2e/ui-appearance.spec.ts b/apps/web/e2e/ui-appearance.spec.ts index e72d12e..6bda945 100644 --- a/apps/web/e2e/ui-appearance.spec.ts +++ b/apps/web/e2e/ui-appearance.spec.ts @@ -67,6 +67,9 @@ test("account settings appearance control switches to light mode", async ({ page await settings.getByRole("button", { name: "Close user settings" }).click(); await expect(settings).toBeHidden(); await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + // Light surfaces stay light: no dark panel or white-on-paper text survives the switch. + await expect(page.locator("main").first()).toHaveCSS("background-color", "rgb(255, 255, 255)"); + await expect(page.locator("aside").first()).toHaveCSS("background-color", "rgb(245, 245, 242)"); await captureScreenshot(page, testInfo, "ui-appearance-light-shell"); await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light"); diff --git a/apps/web/e2e/user-profile-edit.spec.ts b/apps/web/e2e/user-profile-edit.spec.ts new file mode 100644 index 0000000..612ab13 --- /dev/null +++ b/apps/web/e2e/user-profile-edit.spec.ts @@ -0,0 +1,74 @@ +/** + * E2E: Settings → About you edits a fact in place: its wording, category and the spaces it + * shows in. + */ +import { expect, test } from "@playwright/test"; +import type { UserProfileState } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; + +test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester"); + await completeOnboarding(page); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + await rpc(page, "profile/update", { + addFacts: [{ category: "preferences", value: "Prefers tea" }], + }); + + const settings = await openUserSettings(page, "profile"); + const profile = settings.getByTestId("user-profile-settings"); + const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" }); + await tea.getByRole("button", { name: "Edit" }).click(); + + const editor = profile.getByTestId("user-fact-editor"); + const wording = editor.getByRole("textbox", { name: "Fact" }); + await expect(wording).toBeFocused(); + await wording.fill("Prefers green tea"); + await editor.getByRole("combobox", { name: "Category" }).selectOption("habits"); + await editor.getByRole("button", { name: "All spaces" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Side project" }).click(); + await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked(); + await page.keyboard.press("Escape"); + await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible(); + await captureScreenshot(page, testInfo, "user-profile-edit"); + + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + const edited = profile + .getByTestId("user-facts-habits") + .getByTestId("user-fact") + .filter({ hasText: "Prefers green tea" }); + await expect(edited).toContainText("Side project"); + await expect(tea).toHaveCount(0); + const teaFact = async () => + (await rpc(page, "profile/get", null)).facts.find((fact) => + fact.value.includes("tea"), + ); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + category: "habits", + allSpaces: false, + spaceIds: [side.id], + }); + + // Escape leaves the fact as it was; showing it everywhere again clears the limit. + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded"); + await page.keyboard.press("Escape"); + await expect(editor).toHaveCount(0); + await expect(edited).toContainText("Prefers green tea"); + await expect(settings).toBeVisible(); + + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("button", { name: "Side project" }).click(); + await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click(); + await page.keyboard.press("Escape"); + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + await expect(edited).not.toContainText("Side project"); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + allSpaces: true, + spaceIds: [], + }); +}); diff --git a/apps/web/e2e/user-profile.spec.ts b/apps/web/e2e/user-profile.spec.ts index 9b57445..da47b22 100644 --- a/apps/web/e2e/user-profile.spec.ts +++ b/apps/web/e2e/user-profile.spec.ts @@ -1,6 +1,7 @@ /** - * E2E: onboarding answers land in the user profile, and Settings → About you shows every - * fact with its origin, confirms a pending one, and forgets one or all of them. + * E2E: onboarding fills the user profile without asking (time zone, locale and language from + * the browser) and records the focus answer as a fact; Settings → About you shows every fact + * with its origin, confirms a pending one, and forgets one or all of them. */ import { expect, test } from "@playwright/test"; import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers"; @@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } test.use({ timezoneId: "Europe/Vienna" }); test.beforeEach(async ({ page }) => { - // Walk the full emoji-first flow, including the preferences step. + // Walk the full emoji-first flow. await page.route("**/rpc/integrationSetup/get", (route) => route.fulfill({ json: { @@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async }); await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna"); - await page.getByPlaceholder("Your name").fill("Pat Example"); - await page.getByRole("button", { name: "Music", exact: true }).click(); - await page.getByRole("button", { name: "Deep-dive focused" }).click(); - await page.getByRole("button", { name: "Direct & concise" }).click(); - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await page.getByRole("button", { name: "English", exact: true }).click(); await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible(); await page.getByRole("button", { name: "Skip", exact: true }).click(); - await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 }); + await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 }); await waitForChatComposer(page); + // The main bot asks the rest in the main conversation; the focus answer becomes a fact. + await page.getByTestId("focus-choice-day").click(); + await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible(); // The answers no longer overwrite a bot's instructions. const bots = await rpc>(page, "bots/list", null); expect(bots.length).toBeGreaterThan(0); for (const bot of bots) expect(bot.instructions).not.toContain("User Context"); - expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example"); + // The account name is used as is; onboarding no longer asks for it again. + expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester"); // Sensitive facts from anywhere but the user wait for confirmation. await rpc(page, "profile/update", { @@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna"); await expect(profile.getByTestId("profile-field-language")).toContainText("English"); await expect(profile.getByTestId("profile-field-locale")).toContainText("United States"); - await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText( - "Direct and concise", - ); - const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" }); - await expect(music).toContainText("Onboarding"); - await expect(profile.getByTestId("user-facts-habits")).toContainText( - "Prefers focused deep dives", + const focus = profile + .getByTestId("user-fact") + .filter({ hasText: "Wants help with day-to-day work first" }); + await expect(focus).toContainText("Onboarding"); + await expect(profile.getByTestId("user-facts-goals")).toContainText( + "Wants help with day-to-day work first", ); const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" }); @@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async await expect(allergy).toHaveAttribute("data-status", "active"); await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0); - await music.getByRole("button", { name: "Delete" }).click(); - await expect(music).toHaveCount(0); + await focus.getByRole("button", { name: "Delete" }).click(); + await expect(focus).toHaveCount(0); await captureScreenshot(page, testInfo, "user-profile-settings"); const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>( page, @@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async null, ); expect(afterDelete.facts).toEqual( - expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]), + expect.not.arrayContaining([ + expect.objectContaining({ value: "Wants help with day-to-day work first" }), + ]), ); expect(afterDelete.facts).toContainEqual( expect.objectContaining({ value: "Allergic to peanuts", status: "active" }), diff --git a/apps/web/index.html b/apps/web/index.html index c626fbd..a3c98da 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,7 @@ + evOo @@ -26,7 +27,7 @@ document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; const meta = document.querySelector('meta[name="theme-color"]'); - if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a"); + if (meta) meta.setAttribute("content", theme === "light" ? "#ffffff" : "#07080a"); })();