From 1a3829031fb47f6b3ee56583d74c1d61893b51e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:50 +0000 Subject: [PATCH] feat(coach): coach RPCs for goals, habits, check-ins, today and rhythm The web app needs to show and change the user's coach data, and the check-in times need a way to change after onboarding. - coach.today, coach.goals.*, coach.habits.*, coach.checkIns.* and coach.rhythm.get/set, owner-only and not space-scoped; another user's ids read as not found, invalid input as a bad request. - coach-rhythm reuses the shared clock-time helpers. - Tests: an API suite covering ownership and validation for every procedure, the coach tools driven through the main bot (and refused for a specialist), and a morning check-in routine that fires in the main conversation and writes today's brief; the unauthenticated authorization sweep lists the new procedures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/coach.ts | 96 +++++++ apps/api/src/router.ts | 47 ++++ packages/adapters/src/coach-rhythm.ts | 11 +- packages/adapters/src/index.ts | 1 + packages/contracts/src/rpc.ts | 39 +++ packages/testkit/src/authorization.test.ts | 13 + packages/testkit/src/cli/harness.ts | 1 + packages/testkit/src/coach.postgres.test.ts | 284 ++++++++++++++++++++ 8 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/coach.ts create mode 100644 packages/testkit/src/coach.postgres.test.ts 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/router.ts b/apps/api/src/router.ts index b6c7bc6..d0cc995 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -158,6 +158,7 @@ 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, @@ -531,6 +532,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"); @@ -576,6 +578,51 @@ export function createRouter(deps: RouterDeps) { forgetProfile(deps.prisma, 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), diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index 348e26a..6606092 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -1,7 +1,7 @@ import type { JobPublisher } from "@rakazo/adapter-kit"; import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; import type { CoachRhythm } from "@rakazo/contracts"; -import { COACH_RHYTHM_OFF } from "@rakazo/contracts"; +import { COACH_RHYTHM_OFF, clockTimeToMinutes, minutesToClockTime } from "@rakazo/contracts"; import { nextCronDate } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db"; @@ -41,19 +41,16 @@ export interface CoachRhythmDeps { export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null { const slot = key === "weekly" ? rhythm.weekly : rhythm[key]; if (!slot) return null; - const time = typeof slot === "string" ? slot : slot.time; - const [hour, minute] = time.split(":").map(Number); - return `${minute} ${hour} * * ${typeof slot === "string" ? "*" : slot.day}`; + const minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time); + return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`; } -const pad = (value: string) => value.padStart(2, "0"); - /** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */ function rhythmSlot(key: CoachKey, crons: readonly string[]) { const match = crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null; if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return null; - const time = `${pad(match[2]!)}:${pad(match[1]!)}`; + const time = minutesToClockTime(Number(match[2]) * 60 + Number(match[1])); if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time }; return match[3] === "*" ? time : null; } diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 2a841cc..972d83a 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -24,6 +24,7 @@ export * from "./cloud-agent-service.js"; export * from "./cloud-agent-tools.js"; export * from "./cloud-agent-tools-select.js"; export * from "./coach-rhythm.js"; +export * from "./coach-tools.js"; export * from "./composio-catalog-cache.js"; export * from "./composio-connector.js"; export * from "./composio-emulator.js"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1dc59cb..4808b29 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,21 @@ import { eventIterator, oc } from "@orpc/contract"; import * as z from "zod"; import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js"; +import { + CheckInSchema, + CoachRhythmSchema, + CoachTodaySchema, + CreateGoalInput, + CreateHabitInput, + GoalSchema, + HabitSchema, + ListCheckInsInput, + ListGoalsInput, + ListHabitsInput, + LogCheckInInput, + UpdateGoalInput, + UpdateHabitInput, +} from "./coach.js"; import { ActionApprovalRuleSchema, ActionAutoReviewSettingsSchema, @@ -189,6 +204,30 @@ export const appContract = { update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema), forget: oc.input(ForgetUserFactsInput).output(UserProfileStateSchema), }, + /** The user's goals, habits, check-ins and check-in times; owner-only, across spaces. */ + coach: { + today: oc.output(CoachTodaySchema), + goals: { + list: oc.input(ListGoalsInput).output(z.array(GoalSchema)), + create: oc.input(CreateGoalInput).output(GoalSchema), + update: oc.input(UpdateGoalInput).output(GoalSchema), + remove: oc.input(z.object({ goalId: Id })).output(z.object({ ok: z.literal(true) })), + }, + habits: { + list: oc.input(ListHabitsInput).output(z.array(HabitSchema)), + create: oc.input(CreateHabitInput).output(HabitSchema), + update: oc.input(UpdateHabitInput).output(HabitSchema), + remove: oc.input(z.object({ habitId: Id })).output(z.object({ ok: z.literal(true) })), + }, + checkIns: { + log: oc.input(LogCheckInInput).output(CheckInSchema), + list: oc.input(ListCheckInsInput).output(z.array(CheckInSchema)), + }, + rhythm: { + get: oc.output(CoachRhythmSchema), + set: oc.input(CoachRhythmSchema).output(CoachRhythmSchema), + }, + }, spaces: { list: oc.output(SpaceNavigationSchema), create: oc diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index c640e4d..4b75880 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -64,6 +64,19 @@ describeWithDatabase("API authorization and resource isolation", () => { ["profile/get"], ["profile/update", { timeZone: "UTC" }], ["profile/forget", { all: true }], + ["coach/today"], + ["coach/goals/list", {}], + ["coach/goals/create", { title: "Nope" }], + ["coach/goals/update", { goalId: "missing-goal", title: "Nope" }], + ["coach/goals/remove", { goalId: "missing-goal" }], + ["coach/habits/list", {}], + ["coach/habits/create", { title: "Nope" }], + ["coach/habits/update", { habitId: "missing-habit", title: "Nope" }], + ["coach/habits/remove", { habitId: "missing-habit" }], + ["coach/checkIns/log", { kind: "daily", mood: 3 }], + ["coach/checkIns/list", {}], + ["coach/rhythm/get"], + ["coach/rhythm/set", { morning: null, evening: null, weekly: null }], ["deployment/get"], ["deployment/update", { signupsEnabled: true }], ["updater/status"], diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index ffe58e9..21a5f03 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -106,6 +106,7 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/coach.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", diff --git a/packages/testkit/src/coach.postgres.test.ts b/packages/testkit/src/coach.postgres.test.ts new file mode 100644 index 0000000..7f86a1a --- /dev/null +++ b/packages/testkit/src/coach.postgres.test.ts @@ -0,0 +1,284 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { COACH_ROUTINES, ComposioEmulator, SCRIPTED_MORNING_BRIEF } from "@rakazo/adapters"; +import type { + CheckIn, + CoachRhythm, + CoachToday, + Goal, + Habit, + Routine, + SpaceNavigation, +} from "@rakazo/contracts"; +import { ensureMainBot, ensureMainConversation } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; + +describeWithDatabase("coach through the API and the main bot", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-coach-")); + + beforeAll(async () => { + const { createApp } = await import("../../../apps/api/src/app.ts"); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + composio: new ComposioEmulator(), + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + async function waitForRun(runId: string) { + const start = Date.now(); + while (Date.now() - start < 20_000) { + const run = await handles.prisma.run.findUnique({ + where: { id: runId }, + select: { id: true, status: true, error: true, threadId: true }, + }); + if (run && ["completed", "failed", "cancelled"].includes(run.status)) { + expect(run, run.error ?? "").toMatchObject({ status: "completed" }); + return run; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`run ${runId} did not finish`); + } + + async function sendAndWait(cookie: string, botId: string, text: string) { + const { runId } = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId, text }); + return waitForRun(runId); + } + + async function toolOutcome(runId: string) { + const event = await handles.prisma.event.findFirstOrThrow({ + where: { runId, type: "agent.tool.completed" }, + select: { payload: true }, + }); + return event.payload as { name: string; outcome: string; error?: string }; + } + + it("keeps each user's goals, habits and check-ins to that user", async () => { + const ada = await signup(app, `coach-ada-${stamp}@rakazo.test`, "Ada"); + const bob = await signup(app, `coach-bob-${stamp}@rakazo.test`, "Bob"); + const bobSpaces = await rpc(app, bob, "spaces/list"); + + const goal = await rpc(app, ada, "coach/goals/create", { + title: "Ship the beta", + targetDate: "2026-12-01", + }); + expect(goal).toMatchObject({ title: "Ship the beta", status: "active", source: "user" }); + const habit = await rpc(app, ada, "coach/habits/create", { + title: "Write 300 words", + goalId: goal.id, + }); + expect(habit).toMatchObject({ cadence: "daily", goalId: goal.id, source: "user" }); + const checkIn = await rpc(app, ada, "coach/checkIns/log", { + kind: "habit", + habitId: habit.id, + done: true, + }); + await rpc(app, ada, "coach/checkIns/log", { + kind: "goal", + goalId: goal.id, + note: "Draft done", + }); + await rpc(app, ada, "coach/checkIns/log", { kind: "daily", mood: 4, energy: 3 }); + + const today = await rpc(app, ada, "coach/today"); + expect(today).toMatchObject({ date: checkIn.date, timeZone: "UTC", brief: null }); + expect(today.goals.map((g) => g.id)).toEqual([goal.id]); + expect(today.habits).toMatchObject([{ id: habit.id, doneToday: true, streak: 1 }]); + expect(today.checkIn).toMatchObject({ mood: 4, energy: 3 }); + expect(await rpc(app, ada, "coach/checkIns/list", { goalId: goal.id })).toHaveLength( + 1, + ); + + // Bob sees none of it and cannot touch it; Ada's ids read as missing to him. + expect(await rpc(app, bob, "coach/goals/list", {})).toEqual([]); + expect(await rpc(app, bob, "coach/habits/list", {})).toEqual([]); + expect(await rpc(app, bob, "coach/checkIns/list", { habitId: habit.id })).toEqual( + [], + ); + expect((await rpc(app, bob, "coach/today")).habits).toEqual([]); + for (const [procedure, input] of [ + ["coach/goals/update", { goalId: goal.id, status: "dropped" }], + ["coach/goals/remove", { goalId: goal.id }], + ["coach/habits/update", { habitId: habit.id, active: false }], + ["coach/habits/remove", { habitId: habit.id }], + ["coach/checkIns/log", { kind: "habit", habitId: habit.id, done: false }], + ["coach/checkIns/log", { kind: "goal", goalId: goal.id, note: "Mine now" }], + ["coach/habits/create", { title: "Borrowed", goalId: goal.id }], + ] as const) { + expect((await raw(app, bob, procedure, input)).status, procedure).toBe(404); + } + // Ada cannot file a goal under Bob's space. + expect( + ( + await raw(app, ada, "coach/goals/create", { + title: "Elsewhere", + spaceId: bobSpaces.spaces[0]!.id, + }) + ).status, + ).toBe(400); + expect((await raw(app, ada, "coach/goals/create", { title: " " })).status).toBe(400); + expect((await raw(app, ada, "coach/checkIns/log", { kind: "habit" })).status).toBe(400); + expect( + (await raw(app, ada, "coach/checkIns/log", { kind: "daily", date: "2999-01-01" })).status, + ).toBe(400); + expect((await rpc(app, ada, "coach/habits/list", {})).map((h) => h.active)).toEqual([ + true, + ]); + + const closed = await rpc(app, ada, "coach/goals/update", { + goalId: goal.id, + status: "done", + }); + expect(closed.completedAt).not.toBeNull(); + expect((await rpc(app, ada, "coach/today")).goals).toEqual([]); + await rpc(app, ada, "coach/habits/remove", { habitId: habit.id }); + await rpc(app, ada, "coach/goals/remove", { goalId: goal.id }); + expect(await rpc(app, ada, "coach/goals/list", {})).toEqual([]); + expect(await rpc(app, ada, "coach/checkIns/list", { kind: "habit" })).toEqual([]); + }); + + it("lets the main bot coach through its tools, and only the main bot", async () => { + const cookie = await signup(app, `coach-tools-${stamp}@rakazo.test`, "Coach Tools"); + const me = await rpc<{ userId: string }>(app, cookie, "me"); + const main = await ensureMainBot(handles.prisma, me.userId); + const specialist = await rpc<{ id: string }>(app, cookie, "bots/create", { + name: "Researcher", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + + await sendAndWait(cookie, main.id, "set a goal: Run a 10k"); + await sendAndWait(cookie, main.id, "track a habit: Walk"); + await sendAndWait(cookie, main.id, "done today: walk"); + const briefRun = await sendAndWait(cookie, main.id, "write today's brief: Rest, then plan."); + expect(await toolOutcome(briefRun.id)).toMatchObject({ + name: "write_daily_brief", + outcome: "succeeded", + }); + + const today = await rpc(app, cookie, "coach/today"); + expect(today.goals).toMatchObject([ + { title: "Run a 10k", source: "chat", createdByBotId: main.id }, + ]); + expect(today.habits).toMatchObject([ + { title: "Walk", source: "chat", doneToday: true, streak: 1 }, + ]); + expect(today.brief).toMatchObject({ text: "Rest, then plan.", botId: main.id }); + const [walked] = await rpc(app, cookie, "coach/checkIns/list", {}); + expect(walked).toMatchObject({ kind: "habit", done: true, createdByBotId: main.id }); + + const refused = await sendAndWait(cookie, specialist.id, "set a goal: Sneak in"); + expect(await toolOutcome(refused.id)).toMatchObject({ + name: "update_goal", + outcome: "error", + error: expect.stringContaining("not available in this run"), + }); + expect(await rpc(app, cookie, "coach/goals/list", {})).toHaveLength(1); + }); + + it("schedules check-ins as main-bot routines, and the morning one writes today's brief", async () => { + const cookie = await signup(app, `coach-rhythm-${stamp}@rakazo.test`, "Coach Rhythm"); + const me = await rpc<{ userId: string }>(app, cookie, "me"); + expect(await rpc(app, cookie, "coach/rhythm/get")).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + const rhythm = { morning: "07:00", evening: null, weekly: { day: 0, time: "18:00" } }; + expect(await rpc(app, cookie, "coach/rhythm/set", rhythm)).toEqual(rhythm); + expect(await rpc(app, cookie, "coach/rhythm/get")).toEqual(rhythm); + expect((await raw(app, cookie, "coach/rhythm/set", { ...rhythm, morning: "7am" })).status).toBe( + 400, + ); + + const conversation = await ensureMainConversation(handles.prisma, me.userId); + const routines = await rpc(app, cookie, "routines/list", { + botId: conversation.leadBotId, + }); + expect(routines.map((routine) => routine.name).sort()).toEqual( + [COACH_ROUTINES.morning.name, COACH_ROUTINES.weekly.name].sort(), + ); + + const morning = await handles.prisma.routine.findFirstOrThrow({ + where: { botId: conversation.leadBotId, coachKey: "morning" }, + }); + const dueAt = new Date(Date.now() - 1_000); + await handles.prisma.routine.update({ where: { id: morning.id }, data: { nextRunAt: dueAt } }); + await handles.executor.wakeRoutine(morning.id, dueAt.toISOString()); + const run = await handles.prisma.run.findFirstOrThrow({ + where: { routineId: morning.id, trigger: "routine" }, + }); + expect((await waitForRun(run.id)).threadId).toBe(conversation.threadId); + expect((await rpc(app, cookie, "coach/today")).brief).toMatchObject({ + text: SCRIPTED_MORNING_BRIEF, + botId: conversation.leadBotId, + }); + // The routine re-armed for the next morning. + const rearmed = await handles.prisma.routine.findUniqueOrThrow({ where: { id: morning.id } }); + expect(rearmed.active).toBe(true); + expect(rearmed.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie, + origin: "http://127.0.0.1:5173", + }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +}