evOo-local/apps/api/src/autopilot.ts
cynarAI 54658ee689
feat: autopilot progress, composer start and morning summary (#146)
The autopilot's start line counts its steps and folds open to the latest
checkpoints, and its result reads as a card. The composer's plus menu
starts an autopilot with the draft as the order and a choice of until
done, tomorrow morning or a time. Today lists the autopilots that run,
wait or ended within a day, and their step runs no longer show one by
one. Autopilot runs send no completion or failure push; only a pause
notifies.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 04:45:49 +02:00

107 lines
3.5 KiB
TypeScript

import { ORPCError } from "@orpc/server";
import type { JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
import {
autopilotView,
endAutopilot,
findOpenAutopilot,
kickAutopilot,
resumeAutopilot,
startAutopilot,
} from "@rakazo/adapters";
import type { Actor, Autopilot, AutopilotRecent } from "@rakazo/contracts";
import { AUTOPILOT_RECENT_MS } from "@rakazo/contracts";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { IsolationError } from "@rakazo/db";
import type { ThreadTarget } from "./thread-target.js";
import { groupDefaultBotId, stopThreadRuns } from "./thread-target.js";
type AutopilotDeps = {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
sandbox: SandboxProvider;
};
function owner(actor: Actor, target: ThreadTarget) {
const botId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
if (!botId) throw new IsolationError();
return { spaceId: actor.spaceId, userId: actor.userId, botId, threadId: target.threadId };
}
export async function getThreadAutopilot(
deps: Pick<AutopilotDeps, "prisma">,
actor: Actor,
target: ThreadTarget,
): Promise<Autopilot | null> {
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
return open ? autopilotView(open) : null;
}
/** Start from the composer: the first run begins at once. */
export async function startThreadAutopilot(
deps: AutopilotDeps,
actor: Actor,
target: ThreadTarget,
input: { goal: string; focus?: string; until?: string },
): Promise<Autopilot> {
const started = await startAutopilot(deps, owner(actor, target), {
goal: input.goal,
focus: input.focus,
until: input.until ? new Date(input.until) : undefined,
});
if ("error" in started) throw new ORPCError("CONFLICT", { message: started.error });
await kickAutopilot(deps, started.autopilot.id);
return autopilotView(started.autopilot);
}
/** Stop at once: end the autopilot first so no run starts another, then stop what runs. */
export async function stopThreadAutopilot(
deps: AutopilotDeps,
actor: Actor,
target: ThreadTarget,
): Promise<{ ok: true }> {
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
if (open && (await endAutopilot(deps, open.id, { status: "stopped" }))) {
await stopThreadRuns(deps, actor, target);
}
return { ok: true };
}
export async function resumeThreadAutopilot(
deps: AutopilotDeps,
actor: Actor,
target: ThreadTarget,
): Promise<{ ok: true }> {
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
if (open?.status !== "paused" || !(await resumeAutopilot(deps, open.id))) {
throw new ORPCError("CONFLICT", { message: "This autopilot is not paused." });
}
return { ok: true };
}
/** Today's view: the user's open autopilots in this space and those that ended within a day. */
export async function listRecentAutopilots(
deps: Pick<AutopilotDeps, "prisma">,
actor: Actor,
now = new Date(),
): Promise<AutopilotRecent[]> {
const rows = await deps.prisma.autopilot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ status: { in: ["active", "paused"] } },
{ endedAt: { gte: new Date(now.getTime() - AUTOPILOT_RECENT_MS) } },
],
},
include: { thread: { select: { groupId: true } } },
orderBy: { startedAt: "desc" },
take: 10,
});
return rows.map((row) => ({
...autopilotView(row),
spaceId: row.spaceId,
groupId: row.thread.groupId,
}));
}