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

438 lines
16 KiB
TypeScript

import type { JobPublisher } from "@rakazo/adapter-kit";
import { runContinueJob } from "@rakazo/adapter-kit";
import type { AutopilotBlock, Autopilot as AutopilotView } from "@rakazo/contracts";
import { AUTOPILOT_LIMITS } from "@rakazo/contracts";
import {
ACTIVE_RUN_STATUSES,
advanceAutopilotGuard,
autopilotContinuationPrompt,
autopilotExpired,
toolRequiresExplicitApproval,
} from "@rakazo/core";
import type { Prisma, PrismaClient, ThreadEvents } from "@rakazo/db";
import {
appendEventInTransaction,
endAutopilotLinesInTransaction,
postAutopilotLineInTransaction,
recordAutopilotCheckpointInTransaction,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
type AutopilotDeps = {
prisma: PrismaClient;
events: Pick<ThreadEvents, "notify">;
jobs: Pick<JobPublisher, "enqueue">;
};
type AutopilotRow = NonNullable<Awaited<ReturnType<PrismaClient["autopilot"]["findFirst"]>>>;
/** Who the autopilot works for and where. */
export interface AutopilotOwner {
spaceId: string;
userId: string;
botId: string;
threadId: string;
}
const OPEN = ["active", "paused"];
function clip(value: string, max: number) {
const flat = value.replace(/\s+/g, " ").trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
export function autopilotView(row: AutopilotRow): AutopilotView {
return {
id: row.id,
botId: row.botId,
threadId: row.threadId,
goal: row.goal,
focus: row.focus,
until: row.until?.toISOString() ?? null,
status: row.status as AutopilotView["status"],
pauseReason: row.pauseReason,
lastCheckpoint: row.lastCheckpoint,
nextStep: row.nextStep,
result: row.result,
stepCount: row.stepCount,
startedAt: row.startedAt.toISOString(),
endedAt: row.endedAt?.toISOString() ?? null,
};
}
async function notify(events: AutopilotDeps["events"], threadId: string, seq: number | null) {
if (seq === null) return;
await events.notify(threadId, seq).catch((error) => {
getLogger().error("autopilot realtime notification", error);
});
}
async function lockAutopilot(tx: Prisma.TransactionClient, id: string) {
await tx.$queryRaw`SELECT id FROM autopilots WHERE id = ${id} FOR UPDATE`;
return tx.autopilot.findUnique({ where: { id } });
}
/** The thread's open autopilot for this user, active or paused. */
export function findOpenAutopilot(
prisma: PrismaClient | Prisma.TransactionClient,
owner: Pick<AutopilotOwner, "userId" | "threadId">,
) {
return prisma.autopilot.findFirst({
where: { threadId: owner.threadId, userId: owner.userId, status: { in: OPEN } },
orderBy: { startedAt: "desc" },
});
}
/**
* Start an autopilot, or give the open one in this thread a new order. One per user and space:
* an open autopilot in another thread must be stopped first. Approvals granted for it become
* rules that end with it.
*/
export async function startAutopilot(
deps: AutopilotDeps,
owner: AutopilotOwner,
input: { goal: string; focus?: string; until?: Date; allow?: string[] },
): Promise<{ ok: true; autopilot: AutopilotRow } | { error: string }> {
const goal = clip(input.goal, AUTOPILOT_LIMITS.goal);
if (!goal) return { error: "goal is required." };
const focus = input.focus?.trim() ? clip(input.focus, AUTOPILOT_LIMITS.focus) : null;
if (input.until && input.until.getTime() <= Date.now()) {
return { error: "until must be in the future." };
}
const allow = [...new Set((input.allow ?? []).map((name) => name.trim()).filter(Boolean))]
.filter((name) => !toolRequiresExplicitApproval(name))
.slice(0, 20);
const result = await deps.prisma.$transaction(async (tx) => {
// One open autopilot per user and space: serialize starts on the user's space row.
await tx.$queryRaw`SELECT id FROM spaces WHERE id = ${owner.spaceId} FOR UPDATE`;
const open = await tx.autopilot.findFirst({
where: { spaceId: owner.spaceId, userId: owner.userId, status: { in: OPEN } },
});
if (open && open.threadId !== owner.threadId) {
return { error: "Another autopilot is already running in this space. Stop it first." };
}
const data = {
goal,
focus,
until: input.until ?? null,
status: "active",
pauseReason: null,
idleCount: 0,
failCount: 0,
};
const autopilot = open
? await tx.autopilot.update({ where: { id: open.id }, data: { ...data, botId: owner.botId } })
: await tx.autopilot.create({ data: { ...owner, ...data } });
if (open) {
await tx.actionApprovalRule.deleteMany({ where: { autopilotId: open.id } });
await endAutopilotLinesInTransaction(tx, { ...owner, autopilotId: open.id });
}
if (allow.length) {
await tx.actionApprovalRule.createMany({
data: allow.map((name) => ({
spaceId: owner.spaceId,
createdByUserId: owner.userId,
effect: "always_allow",
matchKind: "tool",
matchValue: name,
autopilotId: autopilot.id,
})),
// A standing rule of the user's own stays theirs and outlives the autopilot.
skipDuplicates: true,
});
}
const line = await postAutopilotLineInTransaction(tx, {
...owner,
block: {
kind: "autopilot",
event: "autopilot.started",
autopilotId: autopilot.id,
goal,
...(focus ? { focus } : {}),
...(input.until ? { until: input.until.toISOString() } : {}),
...(allow.length ? { allowed: allow } : {}),
},
});
const updated = await tx.autopilot.update({
where: { id: autopilot.id },
data: { startMessageId: line.messageId },
});
return { ok: true as const, autopilot: updated, seq: line.seq };
});
if ("error" in result) return result;
await notify(deps.events, owner.threadId, result.seq);
return { ok: true, autopilot: result.autopilot };
}
/** A step is done: remember where the work stands. */
export async function checkpointAutopilot(
deps: Pick<AutopilotDeps, "prisma" | "events">,
owner: Pick<AutopilotOwner, "userId" | "threadId">,
input: { summary: string; next?: string },
): Promise<{ ok: true; steps: number } | { error: string }> {
const open = await findOpenAutopilot(deps.prisma, owner);
if (open?.status !== "active") return { error: "No autopilot is running in this thread." };
const summary = clip(input.summary, AUTOPILOT_LIMITS.checkpoint);
const recorded = await deps.prisma.$transaction(async (tx) => {
const autopilot = await lockAutopilot(tx, open.id);
if (autopilot?.status !== "active") return null;
const updated = await tx.autopilot.update({
where: { id: autopilot.id },
data: {
stepCount: { increment: 1 },
idleCount: 0,
lastCheckpoint: summary,
nextStep: input.next?.trim() ? clip(input.next, AUTOPILOT_LIMITS.checkpoint) : null,
},
});
const seq = autopilot.startMessageId
? await recordAutopilotCheckpointInTransaction(tx, {
spaceId: autopilot.spaceId,
threadId: autopilot.threadId,
messageId: autopilot.startMessageId,
autopilotId: autopilot.id,
checkpoint: { text: summary, at: new Date().toISOString() },
steps: updated.stepCount,
})
: null;
return { steps: updated.stepCount, threadId: autopilot.threadId, seq };
});
if (!recorded) return { error: "No autopilot is running in this thread." };
await notify(deps.events, recorded.threadId, recorded.seq);
return { ok: true, steps: recorded.steps };
}
/** End the autopilot: finished with a result, or stopped by the user. */
export async function endAutopilot(
deps: AutopilotDeps,
autopilotId: string,
outcome: { status: "finished"; result: string } | { status: "stopped" },
): Promise<boolean> {
const ended = await deps.prisma.$transaction(async (tx) => {
const autopilot = await lockAutopilot(tx, autopilotId);
if (!autopilot || !OPEN.includes(autopilot.status)) return null;
const endedAt = new Date();
const result =
outcome.status === "finished" ? clip(outcome.result, AUTOPILOT_LIMITS.result) : null;
await tx.autopilot.update({
where: { id: autopilot.id },
data: { status: outcome.status, endedAt, result, pauseReason: null },
});
await tx.actionApprovalRule.deleteMany({ where: { autopilotId: autopilot.id } });
await endAutopilotLinesInTransaction(tx, { ...autopilot, autopilotId: autopilot.id });
const block: AutopilotBlock = {
kind: "autopilot",
event: outcome.status === "finished" ? "autopilot.finished" : "autopilot.stopped",
autopilotId: autopilot.id,
goal: autopilot.goal,
steps: autopilot.stepCount,
durationMs: endedAt.getTime() - autopilot.startedAt.getTime(),
...(result ? { text: result } : {}),
};
const line = await postAutopilotLineInTransaction(tx, { ...autopilot, block });
return { threadId: autopilot.threadId, seq: line.seq };
});
if (!ended) return false;
await notify(deps.events, ended.threadId, ended.seq);
return true;
}
/** Hold the autopilot and say why; the user continues or stops it from the line. */
async function pauseAutopilotInTransaction(
tx: Prisma.TransactionClient,
autopilot: AutopilotRow,
reason: string,
) {
await tx.autopilot.update({
where: { id: autopilot.id },
data: { status: "paused", pauseReason: reason },
});
return postAutopilotLineInTransaction(tx, {
...autopilot,
block: {
kind: "autopilot",
event: "autopilot.paused",
autopilotId: autopilot.id,
goal: autopilot.goal,
steps: autopilot.stepCount,
text: reason,
},
});
}
/** Let a paused autopilot go on, with a fresh idle count, and start its next run. */
export async function resumeAutopilot(deps: AutopilotDeps, autopilotId: string): Promise<boolean> {
const resumed = await deps.prisma.$transaction(async (tx) => {
const autopilot = await lockAutopilot(tx, autopilotId);
if (autopilot?.status !== "paused") return null;
const seq = await endPausedLines(tx, autopilot);
const active = await tx.autopilot.update({
where: { id: autopilot.id },
data: { status: "active", pauseReason: null, idleCount: 0, failCount: 0 },
});
const runId = await createContinuationRun(tx, active);
return { threadId: autopilot.threadId, seq, runId };
});
if (!resumed) return false;
await notify(deps.events, resumed.threadId, resumed.seq);
await enqueue(deps, resumed.runId);
return true;
}
/** Only the pause lines lose their Continue control; the start line keeps its Stop. */
async function endPausedLines(tx: Prisma.TransactionClient, autopilot: AutopilotRow) {
const messages = await tx.message.findMany({
where: {
threadId: autopilot.threadId,
blocks: {
array_contains: [
{ kind: "autopilot", autopilotId: autopilot.id, event: "autopilot.paused" },
],
},
},
select: { id: true, role: true, botId: true, blocks: true },
});
let seq: number | null = null;
for (const message of messages) {
const blocks = (message.blocks as AutopilotBlock[]).map((block) =>
block.kind === "autopilot" && block.event === "autopilot.paused" && !block.ended
? { ...block, ended: true }
: block,
);
await tx.message.update({ where: { id: message.id }, data: { blocks } });
const event = await appendEventInTransaction(tx, {
spaceId: autopilot.spaceId,
threadId: autopilot.threadId,
botId: message.botId ?? autopilot.botId,
type: "thread.message.updated",
payload: { messageId: message.id, role: message.role, blocks },
});
seq = event.seq;
}
return seq;
}
async function enqueue(deps: AutopilotDeps, runId: string | null) {
if (!runId) return;
await deps.jobs.enqueue(runContinueJob(runId)).catch((error) => {
getLogger().error("autopilot continuation enqueue", error);
});
}
/**
* The next run of an active autopilot, unless the bot is already busy in the thread. Each
* continuation has its own nonce, so two workers finishing at once start it only once.
*/
async function createContinuationRun(
tx: Prisma.TransactionClient,
autopilot: AutopilotRow,
): Promise<string | null> {
const busy = await tx.run.findFirst({
where: {
threadId: autopilot.threadId,
botId: autopilot.botId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true },
});
if (busy) return null;
const continuation = autopilot.continuations + 1;
const claimed = await tx.autopilot.updateMany({
where: { id: autopilot.id, continuations: autopilot.continuations },
data: { continuations: continuation },
});
if (claimed.count === 0) return null;
const prompt = autopilotContinuationPrompt(autopilot);
const task = await tx.task.create({
data: {
spaceId: autopilot.spaceId,
botId: autopilot.botId,
threadId: autopilot.threadId,
userId: autopilot.userId,
prompt,
status: "queued",
},
});
const run = await tx.run.create({
data: {
spaceId: autopilot.spaceId,
botId: autopilot.botId,
threadId: autopilot.threadId,
taskId: task.id,
userId: autopilot.userId,
status: "queued",
trigger: "autopilot",
clientNonce: `autopilot:${autopilot.id}:${continuation}`,
},
});
return run.id;
}
/** Start the next run of a just-started autopilot when nothing in the thread runs yet. */
export async function kickAutopilot(deps: AutopilotDeps, autopilotId: string): Promise<void> {
const runId = await deps.prisma.$transaction(async (tx) => {
const autopilot = await lockAutopilot(tx, autopilotId);
return autopilot?.status === "active" ? createContinuationRun(tx, autopilot) : null;
});
await enqueue(deps, runId);
}
/**
* After a run of the autopilot's bot ends in its thread: finish at the chosen time, pause when it
* stopped making progress or keeps failing, and otherwise start the next run.
*/
export async function continueAutopilotAfterRun(
deps: AutopilotDeps,
run: { userId: string; threadId: string; botId: string },
outcome: { checkpointed: boolean; failed: boolean; error?: string },
): Promise<{ paused: string } | null> {
const open = await deps.prisma.autopilot.findFirst({
where: {
threadId: run.threadId,
userId: run.userId,
botId: run.botId,
status: "active",
},
select: { id: true },
});
if (!open) return null;
const next = await deps.prisma.$transaction(async (tx) => {
const autopilot = await lockAutopilot(tx, open.id);
if (autopilot?.status !== "active") return null;
if (autopilotExpired(autopilot.until)) {
return { expired: { id: autopilot.id, lastCheckpoint: autopilot.lastCheckpoint } };
}
const guard = advanceAutopilotGuard(autopilot, outcome);
if (guard.pause) {
const reason =
guard.pause === "failing"
? `Runs keep failing${outcome.error ? `: ${clip(outcome.error, 200)}` : "."}`
: `No progress in ${guard.idleCount} runs${autopilot.lastCheckpoint ? `. Last: ${autopilot.lastCheckpoint}` : "."}`;
const line = await pauseAutopilotInTransaction(tx, autopilot, reason);
return { threadId: autopilot.threadId, seq: line.seq, runId: null, paused: reason };
}
const updated = await tx.autopilot.update({
where: { id: autopilot.id },
data: { idleCount: guard.idleCount, failCount: guard.failCount },
});
return {
threadId: autopilot.threadId,
seq: null,
runId: await createContinuationRun(tx, updated),
paused: null,
};
});
if (!next) return null;
if ("expired" in next && next.expired) {
const autopilot = next.expired;
await endAutopilot(deps, autopilot.id, {
status: "finished",
result: autopilot.lastCheckpoint ?? "Time is up.",
});
return null;
}
await notify(deps.events, next.threadId, next.seq);
await enqueue(deps, next.runId);
return next.paused ? { paused: next.paused } : null;
}