diff --git a/apps/api/src/autopilot.ts b/apps/api/src/autopilot.ts new file mode 100644 index 0000000..eb21f4c --- /dev/null +++ b/apps/api/src/autopilot.ts @@ -0,0 +1,79 @@ +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 } 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, + actor: Actor, + target: ThreadTarget, +): Promise { + 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 { + 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 }; +} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 5eb2e1c..1c19685 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -171,6 +171,12 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec import { createAgentSkillsService } from "./agent-skills.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; +import { + getThreadAutopilot, + resumeThreadAutopilot, + startThreadAutopilot, + stopThreadAutopilot, +} from "./autopilot.js"; import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; import { createCoachService } from "./coach.js"; @@ -1885,6 +1891,8 @@ export function createRouter(deps: RouterDeps) { }), stop: authed.threads.stop.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); + // Stopping the thread also stops its autopilot, or the next run would start right away. + await stopThreadAutopilot(deps, context.actor, target); await stopThreadRuns(deps, context.actor, target); return { ok: true as const }; }), @@ -5142,6 +5150,30 @@ export function createRouter(deps: RouterDeps) { items: await listInbox(deps.prisma, context.actor), })), }, + autopilot: { + get: authed.autopilot.get.handler(async ({ context, input }) => { + const target = await resolveThreadTarget(deps.prisma, context.actor, input); + return getThreadAutopilot(deps, context.actor, target); + }), + start: authed.autopilot.start.handler(async ({ context, input }) => { + if ((await modelSetup(deps, context.actor)).needsModel) { + throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." }); + } + const target = await resolveThreadTarget(deps.prisma, context.actor, input); + return startThreadAutopilot(deps, context.actor, target, input); + }), + stop: authed.autopilot.stop.handler(async ({ context, input }) => { + const target = await resolveThreadTarget(deps.prisma, context.actor, input); + return stopThreadAutopilot(deps, context.actor, target); + }), + resume: authed.autopilot.resume.handler(async ({ context, input }) => { + if ((await modelSetup(deps, context.actor)).needsModel) { + throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." }); + } + const target = await resolveThreadTarget(deps.prisma, context.actor, input); + return resumeThreadAutopilot(deps, context.actor, target); + }), + }, voice: { catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()), status: authed.voice.status.handler(async ({ context }) => { diff --git a/apps/mobile/app/thread.tsx b/apps/mobile/app/thread.tsx index 11d2948..dad5654 100644 --- a/apps/mobile/app/thread.tsx +++ b/apps/mobile/app/thread.tsx @@ -64,6 +64,8 @@ import { useReducedMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppConnectCard } from "../components/AppConnectCard"; import { AskActions } from "../components/AskActions"; +import type { AutopilotAction } from "../components/autopilot-line"; +import { AutopilotLine } from "../components/autopilot-line"; import { BotAvatar } from "../components/bot-avatar"; import { MarkdownArtifactPreview, @@ -1247,6 +1249,18 @@ function Thread() { [botId, groupId], ); + const actOnAutopilot = useCallback( + async (action: AutopilotAction) => { + const targetBotId = botId; + const targetGroupId = groupId; + if (!targetBotId && !targetGroupId) return; + const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }; + await rpc(action === "stop" ? "autopilot/stop" : "autopilot/resume", target); + if (isCurrentTarget(targetBotId, targetGroupId)) await refresh(); + }, + [botId, groupId], + ); + const openBot = useCallback( (id: string, botName: string) => router.push({ pathname: "/thread", params: { botId: id, name: botName } }), @@ -1497,6 +1511,7 @@ function Thread() { canAnswer={message.id === answerableAskMessageId} onAnswer={answerMessage} onCardAct={actOnCard} + onAutopilot={actOnAutopilot} onOpenBot={openBot} onPreviewMarkdown={setMarkdownPreview} actionProps={actionProps} @@ -2372,6 +2387,7 @@ const MessageBubble = memo(function MessageBubble({ canAnswer, onAnswer, onCardAct, + onAutopilot, onOpenBot, onPreviewMarkdown, actionProps, @@ -2386,6 +2402,7 @@ const MessageBubble = memo(function MessageBubble({ canAnswer: boolean; onAnswer: (message: MobileMessage, answer: string) => Promise; onCardAct: (message: MobileMessage, cardId: string, act: CardAct) => Promise; + onAutopilot: (action: AutopilotAction) => Promise; onOpenBot: (botId: string, name: string) => void; onPreviewMarkdown: (target: MarkdownArtifactPreviewTarget) => void; actionProps: MessageActionProps; @@ -2449,7 +2466,11 @@ const MessageBubble = memo(function MessageBubble({ return ( {message.blocks.map((block, index) => - block.kind === "event" ? : null, + block.kind === "event" ? ( + + ) : block.kind === "autopilot" ? ( + + ) : null, )} ); diff --git a/apps/mobile/components/autopilot-line.tsx b/apps/mobile/components/autopilot-line.tsx new file mode 100644 index 0000000..9a56d4c --- /dev/null +++ b/apps/mobile/components/autopilot-line.tsx @@ -0,0 +1,125 @@ +import type { AutopilotBlock } from "@rakazo/contracts"; +import { formatAutopilotDuration } from "@rakazo/core"; +import { useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import { dateLocaleForUi, useI18n } from "../lib/i18n"; +import { useMobileTokens } from "../lib/native"; +import { NativeSymbol } from "./native-symbol"; + +export type AutopilotAction = "stop" | "resume"; + +const ICONS = { + "autopilot.started": { ios: "play.fill", android: "play" }, + "autopilot.paused": { ios: "pause.fill", android: "pause" }, + "autopilot.stopped": { ios: "stop.fill", android: "stop" }, + "autopilot.finished": { ios: "checkmark.circle", android: "checkmark-circle-outline" }, +} as const; + +/** One quiet line when the autopilot starts, pauses, stops or finishes. */ +export function AutopilotLine({ + block, + onAction, +}: { + block: AutopilotBlock; + onAction?: (action: AutopilotAction) => Promise; +}) { + const tokens = useMobileTokens(); + const { t, locale } = useI18n(); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const open = !block.ended && Boolean(onAction); + const muted = { color: tokens.mutedForeground, fontSize: 13.5 }; + + async function act(action: AutopilotAction) { + if (!onAction) return; + setBusy(true); + setFailed(false); + try { + await onAction(action); + } catch { + setFailed(true); + } finally { + setBusy(false); + } + } + + const parts: string[] = []; + let label = t("Autopilot"); + if (block.event === "autopilot.started") { + parts.push(block.goal); + if (block.until) { + const until = new Intl.DateTimeFormat(dateLocaleForUi(locale), { + hour: "numeric", + minute: "2-digit", + }).format(new Date(block.until)); + parts.push(t("until {until}", { until })); + } + if (block.focus) parts.push(block.focus); + if (block.allowed?.length) { + parts.push(t("allowed: {allowed}", { allowed: block.allowed.join(", ") })); + } + } else if (block.event === "autopilot.paused") { + label = t("Autopilot paused"); + if (block.text) parts.push(block.text); + } else { + label = block.event === "autopilot.stopped" ? t("Autopilot stopped") : t("Autopilot finished"); + if (block.durationMs !== undefined) parts.push(formatAutopilotDuration(block.durationMs)); + } + const icon = ICONS[block.event]; + const link = (text: string, action: AutopilotAction) => ( + void act(action)} + style={{ opacity: busy ? 0.5 : 1 }} + > + {text} + + ); + + return ( + + + + {label} + {parts.map((part, index) => ( + + · {part} + + ))} + {open && block.event === "autopilot.paused" ? ( + <> + · + {link(t("Continue"), "resume")} + + ) : null} + {open && (block.event === "autopilot.started" || block.event === "autopilot.paused") ? ( + <> + · + {link(failed ? t("Try again") : t("Stop"), "stop")} + + ) : null} + + {block.event === "autopilot.finished" && block.text ? ( + + {block.text} + + ) : null} + + ); +} diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index d7a1de3..fdb9ed6 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -902,6 +902,7 @@ export function blockText(message: MobileMessage) { } if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; if (block.kind === "event") return block.name; + if (block.kind === "autopilot") return block.text ?? block.goal; if (block.kind === "card") { const header = block.sections?.find((part) => part.type === "header"); return header?.type === "header" diff --git a/apps/mobile/lib/locales/ru.ts b/apps/mobile/lib/locales/ru.ts index 4b527fd..6b28246 100644 --- a/apps/mobile/lib/locales/ru.ts +++ b/apps/mobile/lib/locales/ru.ts @@ -689,4 +689,10 @@ export const RU_MESSAGES: Record = { Accept: "Принять", Dismissed: "Отклонено", Expired: "Истекло", + Autopilot: "Автопилот", + "Autopilot finished": "Автопилот завершён", + "Autopilot paused": "Автопилот на паузе", + "Autopilot stopped": "Автопилот остановлен", + "allowed: {allowed}": "разрешено: {allowed}", + "until {until}": "до {until}", }; diff --git a/apps/mobile/lib/locales/zh.ts b/apps/mobile/lib/locales/zh.ts index eefee23..cba1ef0 100644 --- a/apps/mobile/lib/locales/zh.ts +++ b/apps/mobile/lib/locales/zh.ts @@ -673,4 +673,10 @@ export const ZH_MESSAGES: Record = { Accept: "接受", Dismissed: "已忽略", Expired: "已过期", + Autopilot: "自动驾驶", + "Autopilot finished": "自动驾驶已完成", + "Autopilot paused": "自动驾驶已暂停", + "Autopilot stopped": "自动驾驶已停止", + "allowed: {allowed}": "已允许:{allowed}", + "until {until}": "直到 {until}", }; diff --git a/apps/mobile/lib/message-presentation.ts b/apps/mobile/lib/message-presentation.ts index 5dae1c4..d1a2732 100644 --- a/apps/mobile/lib/message-presentation.ts +++ b/apps/mobile/lib/message-presentation.ts @@ -8,7 +8,8 @@ export function isCenteredAgentEvent(blocks: readonly MessageBlock[]): boolean { block.kind === "bot_message_sent" || block.kind === "bot_message_received" || block.kind === "channel_message" || - block.kind === "event", + block.kind === "event" || + block.kind === "autopilot", ); } diff --git a/apps/web/e2e/autopilot.spec.ts b/apps/web/e2e/autopilot.spec.ts new file mode 100644 index 0000000..8b27eca --- /dev/null +++ b/apps/web/e2e/autopilot.spec.ts @@ -0,0 +1,62 @@ +import { expect, type Page, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers"; + +async function useAppearance(page: Page, appearance: "light" | "dark") { + const settings = await openUserSettings(page); + await settings.getByTestId(`ui-appearance-${appearance}`).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", appearance); + await settings.getByRole("button", { name: "Close user settings" }).click(); + await expect(settings).toBeHidden(); +} + +test("the autopilot works step by step until the order is done", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `autopilot-${stamp}@rakazo.test`, "password12", "Autopilot"); + await completeOnboarding(page); + + await page.getByPlaceholder(/Message/).fill("Work through the night on the tickets."); + await page.keyboard.press("Enter"); + + const start = page.locator('[data-testid="autopilot-line"][data-event="autopilot.started"]'); + await expect(start).toBeVisible({ timeout: 60_000 }); + await expect(start).toContainText("Tidy up the tickets"); + await expect(start).toContainText("Only tidy, change nothing else"); + + const finished = page.locator('[data-testid="autopilot-line"][data-event="autopilot.finished"]'); + await expect(finished).toBeVisible({ timeout: 120_000 }); + await expect(finished).toContainText("Tidied 5 tickets; nothing waits on you."); + // Once it is over, the start line has no Stop any more. + await expect(start.getByRole("button", { name: "Stop" })).toHaveCount(0); + + await useAppearance(page, "light"); + await finished.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "autopilot-finished-light"); + await useAppearance(page, "dark"); + await finished.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "autopilot-finished-dark"); +}); + +test("an autopilot without progress pauses and stops on request", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `autopilot-idle-${stamp}@rakazo.test`, "password12", "Autopilot Idle"); + await completeOnboarding(page); + + await page.getByPlaceholder(/Message/).fill("Work through the night, idle check."); + await page.keyboard.press("Enter"); + + const paused = page.locator('[data-testid="autopilot-line"][data-event="autopilot.paused"]'); + await expect(paused).toBeVisible({ timeout: 120_000 }); + await expect(paused).toContainText("No progress in 3 runs"); + await expect(paused.getByRole("button", { name: "Continue" })).toBeVisible(); + await useAppearance(page, "dark"); + await paused.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "autopilot-paused-dark"); + + await paused.getByRole("button", { name: "Stop" }).click(); + const stopped = page.locator('[data-testid="autopilot-line"][data-event="autopilot.stopped"]'); + await expect(stopped).toBeVisible({ timeout: 30_000 }); + await expect(paused.getByRole("button", { name: "Continue" })).toHaveCount(0); + await useAppearance(page, "light"); + await stopped.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "autopilot-paused-light"); +}); diff --git a/apps/web/scripts/translations-de.json b/apps/web/scripts/translations-de.json index adbb5f2..e6058c0 100644 --- a/apps/web/scripts/translations-de.json +++ b/apps/web/scripts/translations-de.json @@ -639,5 +639,11 @@ "Stop timer": "Timer stoppen", "Accept": "Annehmen", "Dismissed": "Abgelehnt", - "Expired": "Abgelaufen" + "Expired": "Abgelaufen", + "Autopilot": "Autopilot", + "Autopilot paused": "Autopilot pausiert", + "Autopilot stopped": "Autopilot gestoppt", + "Autopilot finished": "Autopilot beendet", + "until {until}": "bis {until}", + "allowed: {allowed}": "erlaubt: {allowed}" } diff --git a/apps/web/src/lib/thread-events.ts b/apps/web/src/lib/thread-events.ts index 4379c38..705e910 100644 --- a/apps/web/src/lib/thread-events.ts +++ b/apps/web/src/lib/thread-events.ts @@ -35,6 +35,7 @@ const runTriggers = new Set([ "delegation", "media", "suggestion", + "autopilot", ]); function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run { diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index a95ba7b..9e63777 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -572,6 +572,10 @@ msgstr "Schreiben erlauben" msgid "Allowed once" msgstr "Einmal erlaubt" +#: src/pages/shell/autopilot-line.tsx +msgid "allowed: {allowed}" +msgstr "erlaubt: {allowed}" + #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "Du hast bereits ein Konto?" @@ -760,6 +764,22 @@ msgstr "Automatische Absender" msgid "Automated senders will appear after they post here." msgstr "Automatische Absender erscheinen, sobald sie hier etwas posten." +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot" +msgstr "Autopilot" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot finished" +msgstr "Autopilot beendet" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot paused" +msgstr "Autopilot pausiert" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot stopped" +msgstr "Autopilot gestoppt" + #: src/pages/RoutineEditor.tsx msgid "Available after the routine is saved" msgstr "Verfügbar, sobald die Routine gespeichert ist" @@ -1415,6 +1435,7 @@ msgstr "Kontextlimit" #: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/pages/shell/autopilot-line.tsx msgid "Continue" msgstr "Weiter" @@ -4801,6 +4822,7 @@ msgid "Steps" msgstr "Schritte" #: src/components/browser/InAppBrowser.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/composer.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/transcript.tsx @@ -5087,6 +5109,7 @@ msgid "Triage in progress…" msgstr "Triage läuft…" #: src/pages/Onboarding.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Erneut versuchen" @@ -5136,6 +5159,10 @@ msgstr "Ungelesene Antwort" msgid "Unsupported file type: {0}" msgstr "Nicht unterstützter Dateityp: {0}" +#: src/pages/shell/autopilot-line.tsx +msgid "until {until}" +msgstr "bis {until}" + #: src/components/DesktopUpdates.tsx #: src/components/SoftwareUpdateSection.tsx msgid "Up to date" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index 3c2d3fc..a36cb4f 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -572,6 +572,10 @@ msgstr "Allow writing" msgid "Allowed once" msgstr "Allowed once" +#: src/pages/shell/autopilot-line.tsx +msgid "allowed: {allowed}" +msgstr "allowed: {allowed}" + #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "Already have an account?" @@ -760,6 +764,22 @@ msgstr "Automated senders" msgid "Automated senders will appear after they post here." msgstr "Automated senders will appear after they post here." +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot" +msgstr "Autopilot" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot finished" +msgstr "Autopilot finished" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot paused" +msgstr "Autopilot paused" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot stopped" +msgstr "Autopilot stopped" + #: src/pages/RoutineEditor.tsx msgid "Available after the routine is saved" msgstr "Available after the routine is saved" @@ -1415,6 +1435,7 @@ msgstr "Context limit" #: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/pages/shell/autopilot-line.tsx msgid "Continue" msgstr "Continue" @@ -4801,6 +4822,7 @@ msgid "Steps" msgstr "Steps" #: src/components/browser/InAppBrowser.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/composer.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/transcript.tsx @@ -5087,6 +5109,7 @@ msgid "Triage in progress…" msgstr "Triage in progress…" #: src/pages/Onboarding.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Try again" @@ -5136,6 +5159,10 @@ msgstr "Unread reply" msgid "Unsupported file type: {0}" msgstr "Unsupported file type: {0}" +#: src/pages/shell/autopilot-line.tsx +msgid "until {until}" +msgstr "until {until}" + #: src/components/DesktopUpdates.tsx #: src/components/SoftwareUpdateSection.tsx msgid "Up to date" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 6467d8c..d905071 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -572,6 +572,10 @@ msgstr "" msgid "Allowed once" msgstr "Permitido una vez" +#: src/pages/shell/autopilot-line.tsx +msgid "allowed: {allowed}" +msgstr "" + #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "¿Ya tienes una cuenta?" @@ -760,6 +764,22 @@ msgstr "" msgid "Automated senders will appear after they post here." msgstr "" +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot finished" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot paused" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot stopped" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "Available after the routine is saved" msgstr "Disponible después de guardar la rutina" @@ -1415,6 +1435,7 @@ msgstr "Límite de contexto" #: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/pages/shell/autopilot-line.tsx msgid "Continue" msgstr "Continuar" @@ -4801,6 +4822,7 @@ msgid "Steps" msgstr "Pasos" #: src/components/browser/InAppBrowser.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/composer.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/transcript.tsx @@ -5087,6 +5109,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Intentarlo de nuevo" @@ -5136,6 +5159,10 @@ msgstr "" msgid "Unsupported file type: {0}" msgstr "Tipo de archivo no compatible: {0}" +#: src/pages/shell/autopilot-line.tsx +msgid "until {until}" +msgstr "" + #: src/components/DesktopUpdates.tsx #: src/components/SoftwareUpdateSection.tsx msgid "Up to date" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index 2610172..891d64c 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -572,6 +572,10 @@ msgstr "" msgid "Allowed once" msgstr "एक बार अनुमति दी गई" +#: src/pages/shell/autopilot-line.tsx +msgid "allowed: {allowed}" +msgstr "" + #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "पहले से खाता है?" @@ -760,6 +764,22 @@ msgstr "" msgid "Automated senders will appear after they post here." msgstr "" +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot finished" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot paused" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot stopped" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "Available after the routine is saved" msgstr "" @@ -1415,6 +1435,7 @@ msgstr "कॉन्टेक्स्ट सीमा" #: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/pages/shell/autopilot-line.tsx msgid "Continue" msgstr "जारी रखें" @@ -4801,6 +4822,7 @@ msgid "Steps" msgstr "चरण" #: src/components/browser/InAppBrowser.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/composer.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/transcript.tsx @@ -5087,6 +5109,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "फिर से कोशिश करें" @@ -5136,6 +5159,10 @@ msgstr "" msgid "Unsupported file type: {0}" msgstr "असमर्थित फ़ाइल प्रकार: {0}" +#: src/pages/shell/autopilot-line.tsx +msgid "until {until}" +msgstr "" + #: src/components/DesktopUpdates.tsx #: src/components/SoftwareUpdateSection.tsx msgid "Up to date" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index 43ebeb9..16a6a6e 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -572,6 +572,10 @@ msgstr "" msgid "Allowed once" msgstr "한 번 허용됨" +#: src/pages/shell/autopilot-line.tsx +msgid "allowed: {allowed}" +msgstr "" + #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "이미 계정이 있나요?" @@ -760,6 +764,22 @@ msgstr "" msgid "Automated senders will appear after they post here." msgstr "" +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot finished" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot paused" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot stopped" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "Available after the routine is saved" msgstr "루틴을 저장한 후 사용할 수 있습니다" @@ -1415,6 +1435,7 @@ msgstr "컨텍스트 한도" #: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/pages/shell/autopilot-line.tsx msgid "Continue" msgstr "계속" @@ -4801,6 +4822,7 @@ msgid "Steps" msgstr "실행 단계" #: src/components/browser/InAppBrowser.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/composer.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/transcript.tsx @@ -5087,6 +5109,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "다시 시도" @@ -5136,6 +5159,10 @@ msgstr "" msgid "Unsupported file type: {0}" msgstr "지원하지 않는 파일 형식: {0}" +#: src/pages/shell/autopilot-line.tsx +msgid "until {until}" +msgstr "" + #: src/components/DesktopUpdates.tsx #: src/components/SoftwareUpdateSection.tsx msgid "Up to date" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index 52aa207..61cbdd3 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -572,6 +572,10 @@ msgstr "" msgid "Allowed once" msgstr "Permitido uma vez" +#: src/pages/shell/autopilot-line.tsx +msgid "allowed: {allowed}" +msgstr "" + #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "Você já tem cadastro?" @@ -760,6 +764,22 @@ msgstr "" msgid "Automated senders will appear after they post here." msgstr "" +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot finished" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot paused" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot stopped" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "Available after the routine is saved" msgstr "" @@ -1415,6 +1435,7 @@ msgstr "Limite de contexto" #: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/pages/shell/autopilot-line.tsx msgid "Continue" msgstr "Continuar" @@ -4801,6 +4822,7 @@ msgid "Steps" msgstr "Passos" #: src/components/browser/InAppBrowser.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/composer.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/transcript.tsx @@ -5087,6 +5109,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Tentar novamente" @@ -5136,6 +5159,10 @@ msgstr "" msgid "Unsupported file type: {0}" msgstr "Tipo de arquivo não suportado: {0}" +#: src/pages/shell/autopilot-line.tsx +msgid "until {until}" +msgstr "" + #: src/components/DesktopUpdates.tsx #: src/components/SoftwareUpdateSection.tsx msgid "Up to date" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index e36e5a0..4d0801e 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -572,6 +572,10 @@ msgstr "" msgid "Allowed once" msgstr "Разрешено один раз" +#: src/pages/shell/autopilot-line.tsx +msgid "allowed: {allowed}" +msgstr "" + #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "У вас уже есть аккаунт?" @@ -760,6 +764,22 @@ msgstr "Автоматические отправители" msgid "Automated senders will appear after they post here." msgstr "Автоматические отправители появятся после публикации здесь." +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot finished" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot paused" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot stopped" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "Available after the routine is saved" msgstr "Доступно после сохранения задачи" @@ -1415,6 +1435,7 @@ msgstr "" #: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/pages/shell/autopilot-line.tsx msgid "Continue" msgstr "Продолжить" @@ -4801,6 +4822,7 @@ msgid "Steps" msgstr "Шаги" #: src/components/browser/InAppBrowser.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/composer.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/transcript.tsx @@ -5087,6 +5109,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "" @@ -5136,6 +5159,10 @@ msgstr "" msgid "Unsupported file type: {0}" msgstr "Неподдерживаемый тип файла: {0}." +#: src/pages/shell/autopilot-line.tsx +msgid "until {until}" +msgstr "" + #: src/components/DesktopUpdates.tsx #: src/components/SoftwareUpdateSection.tsx msgid "Up to date" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index a4bed24..a54d287 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -572,6 +572,10 @@ msgstr "" msgid "Allowed once" msgstr "Bir kez izin verildi" +#: src/pages/shell/autopilot-line.tsx +msgid "allowed: {allowed}" +msgstr "" + #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "Zaten hesabın var mı?" @@ -760,6 +764,22 @@ msgstr "" msgid "Automated senders will appear after they post here." msgstr "" +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot finished" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot paused" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot stopped" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "Available after the routine is saved" msgstr "" @@ -1415,6 +1435,7 @@ msgstr "Bağlam sınırı" #: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/pages/shell/autopilot-line.tsx msgid "Continue" msgstr "Devam" @@ -4801,6 +4822,7 @@ msgid "Steps" msgstr "Adımlar" #: src/components/browser/InAppBrowser.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/composer.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/transcript.tsx @@ -5087,6 +5109,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Tekrar dene" @@ -5136,6 +5159,10 @@ msgstr "" msgid "Unsupported file type: {0}" msgstr "Desteklenmeyen dosya türü: {0}" +#: src/pages/shell/autopilot-line.tsx +msgid "until {until}" +msgstr "" + #: src/components/DesktopUpdates.tsx #: src/components/SoftwareUpdateSection.tsx msgid "Up to date" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index a524107..b18c72a 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -572,6 +572,10 @@ msgstr "" msgid "Allowed once" msgstr "已允许一次" +#: src/pages/shell/autopilot-line.tsx +msgid "allowed: {allowed}" +msgstr "" + #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "已有账户?" @@ -760,6 +764,22 @@ msgstr "" msgid "Automated senders will appear after they post here." msgstr "" +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot finished" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot paused" +msgstr "" + +#: src/pages/shell/autopilot-line.tsx +msgid "Autopilot stopped" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "Available after the routine is saved" msgstr "保存例行任务后可用" @@ -1415,6 +1435,7 @@ msgstr "上下文限制" #: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/pages/shell/autopilot-line.tsx msgid "Continue" msgstr "继续" @@ -4801,6 +4822,7 @@ msgid "Steps" msgstr "步骤" #: src/components/browser/InAppBrowser.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/composer.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/transcript.tsx @@ -5087,6 +5109,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/autopilot-line.tsx #: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "重试" @@ -5136,6 +5159,10 @@ msgstr "" msgid "Unsupported file type: {0}" msgstr "不支持的文件类型:{0}" +#: src/pages/shell/autopilot-line.tsx +msgid "until {until}" +msgstr "" + #: src/components/DesktopUpdates.tsx #: src/components/SoftwareUpdateSection.tsx msgid "Up to date" diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index d8a3aba..48d6d90 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -155,6 +155,7 @@ import { import type { SettingsSection } from "./SettingsOverlay"; import { SpaceSearchResults } from "./SpaceSearch"; import { ArchivedSection } from "./shell/archived-section"; +import type { AutopilotAction } from "./shell/autopilot-line"; import { BotSettings, CreateBotForm } from "./shell/bot-panel"; import { ConversationBotPicker } from "./shell/bot-picker"; import { @@ -1978,6 +1979,19 @@ export function ShellPage() { await refreshThreadRef.current(botId); } }, []); + const actOnAutopilot = useCallback(async (action: AutopilotAction) => { + const botId = activeBotId.current; + const groupId = activeGroupId.current; + if (!botId && !groupId) return; + const target = groupId ? { groupId } : { botId: botId! }; + if (action === "stop") await rpc.autopilot.stop(target); + else await rpc.autopilot.resume(target); + if (groupId && activeGroupId.current === groupId) { + await refreshGroupThreadRef.current(groupId); + } else if (botId && activeBotId.current === botId) { + await refreshThreadRef.current(botId); + } + }, []); const reactToMessage = useCallback( async (message: ThreadMessage, reaction: MessageReaction) => { const botId = activeBotId.current; @@ -3330,6 +3344,7 @@ export function ShellPage() { onOpenGroup={openGroup} onAnswer={answerMessage} onCardAct={actOnCard} + onAutopilot={actOnAutopilot} onReply={(message) => { setReplyTarget(message); setReplyQuote(null); diff --git a/apps/web/src/pages/shell/autopilot-line.tsx b/apps/web/src/pages/shell/autopilot-line.tsx new file mode 100644 index 0000000..6258411 --- /dev/null +++ b/apps/web/src/pages/shell/autopilot-line.tsx @@ -0,0 +1,123 @@ +import { useLingui } from "@lingui/react/macro"; +import type { AutopilotBlock } from "@rakazo/contracts"; +import { formatAutopilotDuration } from "@rakazo/core"; +import { Icon } from "@rakazo/ui-web"; +import { CircleCheck, Pause, Play, Square } from "lucide-react"; +import { useState } from "react"; + +export type AutopilotAction = "stop" | "resume"; + +const actionClass = + "rounded-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"; + +/** + * One quiet line when the autopilot starts, pauses, stops or finishes. Stop stays on the start + * line while it runs; a pause offers Continue. + */ +export function AutopilotLine({ + block, + onAction, +}: { + block: AutopilotBlock; + onAction?: (action: AutopilotAction) => Promise; +}) { + const { i18n, t } = useLingui(); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const open = !block.ended && Boolean(onAction); + + async function act(action: AutopilotAction) { + if (!onAction) return; + setBusy(true); + setFailed(false); + try { + await onAction(action); + } catch { + setFailed(true); + } finally { + setBusy(false); + } + } + + const until = block.until + ? new Intl.DateTimeFormat(i18n.locale || "en", { hour: "numeric", minute: "2-digit" }).format( + new Date(block.until), + ) + : null; + const duration = + block.durationMs !== undefined ? formatAutopilotDuration(block.durationMs) : null; + const parts: string[] = []; + let icon = Play; + let label = t`Autopilot`; + if (block.event === "autopilot.started") { + parts.push(block.goal); + if (until) parts.push(t`until ${until}`); + if (block.focus) parts.push(block.focus); + if (block.allowed?.length) { + const allowed = block.allowed.join(", "); + parts.push(t`allowed: ${allowed}`); + } + } else if (block.event === "autopilot.paused") { + icon = Pause; + label = t`Autopilot paused`; + if (block.text) parts.push(block.text); + } else if (block.event === "autopilot.stopped") { + icon = Square; + label = t`Autopilot stopped`; + if (duration) parts.push(duration); + } else { + icon = CircleCheck; + label = t`Autopilot finished`; + if (duration) parts.push(duration); + } + + return ( +
+

+ + {label} + {parts.map((part, index) => ( + + · + {part} + + ))} + {open && block.event === "autopilot.paused" ? ( + <> + · + + + ) : null} + {open && (block.event === "autopilot.started" || block.event === "autopilot.paused") ? ( + <> + · + + + ) : null} +

+ {block.event === "autopilot.finished" && block.text ? ( +

+ {block.text} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx index 25da752..b047740 100644 --- a/apps/web/src/pages/shell/transcript.tsx +++ b/apps/web/src/pages/shell/transcript.tsx @@ -44,6 +44,8 @@ import { messageProviderLabel } from "../../lib/messaging"; import { withIntakeNoteText } from "../../lib/onboarding-copy"; import { quoteDraftForSelection } from "../../lib/quote-selection"; import { transcriptIsNearEnd, transcriptMovedDown } from "../../lib/transcript-scroll"; +import type { AutopilotAction } from "./autopilot-line"; +import { AutopilotLine } from "./autopilot-line"; import { AppConnectCard, ArtifactImage, @@ -70,6 +72,7 @@ export const Transcript = memo(function Transcript({ onOpenGroup, onAnswer, onCardAct, + onAutopilot, onReply, onQuote, onReact, @@ -101,6 +104,7 @@ export const Transcript = memo(function Transcript({ onOpenGroup: (groupId: string) => void; onAnswer: (message: ThreadMessage, text: string) => Promise; onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise; + onAutopilot?: (action: AutopilotAction) => Promise; onReply: (message: ThreadMessage) => void; onQuote: (message: ThreadMessage, quote: string) => void; onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise; @@ -380,7 +384,11 @@ export const Transcript = memo(function Transcript({ return (
{message.blocks.map((block, index) => - block.kind === "event" ? : null, + block.kind === "event" ? ( + + ) : block.kind === "autopilot" ? ( + + ) : null, )}
); diff --git a/packages/adapters/src/autopilot.postgres.test.ts b/packages/adapters/src/autopilot.postgres.test.ts new file mode 100644 index 0000000..80a5b5c --- /dev/null +++ b/packages/adapters/src/autopilot.postgres.test.ts @@ -0,0 +1,193 @@ +import { randomUUID } from "node:crypto"; +import type { AutopilotBlock, MessageBlock } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + checkpointAutopilot, + continueAutopilotAfterRun, + endAutopilot, + findOpenAutopilot, + resumeAutopilot, + startAutopilot, +} from "./autopilot.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("autopilot (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + async function newOwner() { + const id = `ap${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); + const conversation = await ensureMainConversation(prisma, id); + const enqueue = vi.fn(async () => undefined); + const deps = { + prisma, + events: { notify: vi.fn(async () => undefined) }, + jobs: { enqueue }, + }; + const owner = { + spaceId: conversation.spaceId, + userId: id, + botId: conversation.leadBotId, + threadId: conversation.threadId, + }; + return { deps, owner, enqueue }; + } + + async function lines(threadId: string) { + const messages = await prisma.message.findMany({ + where: { threadId }, + orderBy: { seq: "asc" }, + select: { blocks: true }, + }); + return messages.flatMap((message) => + (message.blocks as MessageBlock[]).filter( + (block): block is AutopilotBlock => block.kind === "autopilot", + ), + ); + } + + async function finishQueuedRuns(threadId: string) { + await prisma.run.updateMany({ + where: { threadId, status: "queued" }, + data: { status: "completed" }, + }); + } + + it("works step by step until it finishes, then takes its approvals away", async () => { + const { deps, owner, enqueue } = await newOwner(); + const started = await startAutopilot(deps, owner, { + goal: "Tidy up the tickets", + allow: ["github_commit", "create_space"], + }); + if (!("ok" in started)) throw new Error(started.error); + const rules = await prisma.actionApprovalRule.findMany({ + where: { autopilotId: started.autopilot.id }, + }); + // A security-boundary tool is never granted in advance. + expect(rules.map((rule) => rule.matchValue)).toEqual(["github_commit"]); + + for (let step = 1; step <= 4; step += 1) { + await checkpointAutopilot(prisma, owner, { summary: `Ticket ${step} tidied` }); + await continueAutopilotAfterRun(deps, owner, { checkpointed: true, failed: false }); + await finishQueuedRuns(owner.threadId); + } + const runs = await prisma.run.findMany({ + where: { threadId: owner.threadId, trigger: "autopilot" }, + include: { task: true }, + orderBy: { createdAt: "asc" }, + }); + expect(runs).toHaveLength(4); + expect(runs.map((run) => run.clientNonce)).toEqual( + [1, 2, 3, 4].map((n) => `autopilot:${started.autopilot.id}:${n}`), + ); + expect(runs[3]?.task.prompt).toContain("Last checkpoint: Ticket 4 tidied"); + expect(enqueue).toHaveBeenCalledTimes(4); + + expect( + await endAutopilot(deps, started.autopilot.id, { status: "finished", result: "Done." }), + ).toBe(true); + const row = await prisma.autopilot.findUniqueOrThrow({ where: { id: started.autopilot.id } }); + expect(row).toMatchObject({ status: "finished", stepCount: 4, result: "Done." }); + expect( + await prisma.actionApprovalRule.count({ where: { autopilotId: started.autopilot.id } }), + ).toBe(0); + const timeline = await lines(owner.threadId); + expect(timeline.map((line) => [line.event, Boolean(line.ended)])).toEqual([ + ["autopilot.started", true], + ["autopilot.finished", false], + ]); + // Nothing runs after the end. + await continueAutopilotAfterRun(deps, owner, { checkpointed: false, failed: false }); + expect(enqueue).toHaveBeenCalledTimes(4); + }); + + it("pauses after three runs without progress and goes on when the user continues", async () => { + const { deps, owner, enqueue } = await newOwner(); + const started = await startAutopilot(deps, owner, { goal: "Hunt bugs" }); + if (!("ok" in started)) throw new Error(started.error); + for (let run = 0; run < 3; run += 1) { + await continueAutopilotAfterRun(deps, owner, { checkpointed: false, failed: false }); + await finishQueuedRuns(owner.threadId); + } + expect(enqueue).toHaveBeenCalledTimes(2); + const paused = await findOpenAutopilot(prisma, owner); + expect(paused).toMatchObject({ status: "paused" }); + expect(paused?.pauseReason).toContain("No progress in 3 runs"); + expect((await lines(owner.threadId)).at(-1)).toMatchObject({ event: "autopilot.paused" }); + + expect(await resumeAutopilot(deps, paused!.id)).toBe(true); + expect(enqueue).toHaveBeenCalledTimes(3); + expect(await findOpenAutopilot(prisma, owner)).toMatchObject({ + status: "active", + idleCount: 0, + }); + expect((await lines(owner.threadId)).at(-1)).toMatchObject({ + event: "autopilot.paused", + ended: true, + }); + }); + + it("runs one autopilot per space, ends at its time and stops at once", async () => { + const { deps, owner, enqueue } = await newOwner(); + const first = await startAutopilot(deps, owner, { + goal: "Work through the night", + until: new Date(Date.now() + 60_000), + }); + if (!("ok" in first)) throw new Error(first.error); + const elsewhere = await prisma.thread.findFirstOrThrow({ + where: { spaceId: owner.spaceId, id: { not: owner.threadId } }, + }); + expect( + await startAutopilot(deps, { ...owner, threadId: elsewhere.id }, { goal: "Second" }), + ).toEqual({ error: expect.stringContaining("Stop it first") }); + + await prisma.autopilot.update({ + where: { id: first.autopilot.id }, + data: { until: new Date(Date.now() - 1000), lastCheckpoint: "Half the tickets" }, + }); + await continueAutopilotAfterRun(deps, owner, { checkpointed: true, failed: false }); + expect(enqueue).not.toHaveBeenCalled(); + expect( + await prisma.autopilot.findUniqueOrThrow({ where: { id: first.autopilot.id } }), + ).toMatchObject({ status: "finished", result: "Half the tickets" }); + + const second = await startAutopilot(deps, owner, { goal: "Again" }); + if (!("ok" in second)) throw new Error(second.error); + expect(await endAutopilot(deps, second.autopilot.id, { status: "stopped" })).toBe(true); + expect(await endAutopilot(deps, second.autopilot.id, { status: "stopped" })).toBe(false); + expect((await lines(owner.threadId)).at(-1)).toMatchObject({ event: "autopilot.stopped" }); + }); +}); diff --git a/packages/adapters/src/autopilot.ts b/packages/adapters/src/autopilot.ts new file mode 100644 index 0000000..b92d271 --- /dev/null +++ b/packages/adapters/src/autopilot.ts @@ -0,0 +1,416 @@ +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, +} from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; + +type AutopilotDeps = { + prisma: PrismaClient; + events: Pick; + jobs: Pick; +}; + +type AutopilotRow = NonNullable>>; + +/** 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, +) { + 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( + prisma: PrismaClient, + owner: Pick, + input: { summary: string; next?: string }, +): Promise<{ ok: true; steps: number } | { error: string }> { + const open = await findOpenAutopilot(prisma, owner); + if (open?.status !== "active") return { error: "No autopilot is running in this thread." }; + const updated = await prisma.autopilot.update({ + where: { id: open.id }, + data: { + stepCount: { increment: 1 }, + idleCount: 0, + lastCheckpoint: clip(input.summary, AUTOPILOT_LIMITS.checkpoint), + nextStep: input.next?.trim() ? clip(input.next, AUTOPILOT_LIMITS.checkpoint) : null, + }, + }); + return { ok: true, steps: updated.stepCount }; +} + +/** 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 { + 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 { + 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 { + 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 { + 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 { + 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; + 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 }; + } + 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), + }; + }); + if (!next) return; + if ("expired" in next && next.expired) { + const autopilot = next.expired; + await endAutopilot(deps, autopilot.id, { + status: "finished", + result: autopilot.lastCheckpoint ?? "Time is up.", + }); + return; + } + await notify(deps.events, next.threadId, next.seq); + await enqueue(deps, next.runId); +} diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 2103a85..81ec4b6 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -367,6 +367,67 @@ export const builtinAgentTools: ConnectorTool[] = [ required: ["tool", "args"], }, }, + { + name: "autopilot_start", + description: + "Keep working on the user's order on your own, run after run, with no time limit: when the user asks you to work through the night, keep going until something is done, or only hunt bugs for a while. Starts at once and shows a line with Stop in the conversation. Calling it again in this thread replaces the order.", + inputSchema: { + type: "object", + properties: { + goal: { type: "string", maxLength: 500, description: "The order in the user's words." }, + focus: { + type: "string", + maxLength: 200, + description: 'An optional limit the user set, such as "only find bugs, change nothing".', + }, + until: { + type: "string", + description: "ISO datetime to stop at. Omit to work until the order is done.", + }, + allow: { + type: "array", + items: { type: "string" }, + description: + "Tool names the user explicitly allowed without asking while this runs, such as a commit tool. Only what the user said; the rest is parked for their approval.", + }, + }, + required: ["goal"], + }, + }, + { + name: "autopilot_checkpoint", + description: + "Report a finished step of the autopilot: what changed, and what comes next. Call it after every step; without it the autopilot pauses.", + inputSchema: { + type: "object", + properties: { + summary: { type: "string", maxLength: 300, description: "What this step achieved." }, + next: { type: "string", maxLength: 300, description: "The next step you plan." }, + }, + required: ["summary"], + }, + }, + { + name: "autopilot_finish", + description: "End the autopilot because the order is done, with the result for the user.", + inputSchema: { + type: "object", + properties: { + result: { + type: "string", + maxLength: 2000, + description: "What was done, what is left, and what waits on the user.", + }, + }, + required: ["result"], + }, + }, + { + name: "autopilot_stop", + description: + 'Stop the autopilot in this thread when the user asks you to ("stop", "that\'s enough").', + inputSchema: { type: "object", properties: {} }, + }, { name: "message_user", description: diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index bf14ffc..34d5577 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -147,6 +147,7 @@ export function selectBuiltinToolsForRun(options: { "show_card", "suggest", ].includes(tool.name) && + !tool.name.startsWith("autopilot_") && !USER_MODEL_TOOLS.has(tool.name) && !tool.name.startsWith("scratchpad_"))), ); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 917f76c..ac8068e 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -156,6 +156,13 @@ import { resolveAutoReviewChecker, runAutoReviewJudge, } from "./auto-review.js"; +import { + checkpointAutopilot, + continueAutopilotAfterRun, + endAutopilot, + findOpenAutopilot, + startAutopilot, +} from "./autopilot.js"; import { loadBotMessageContext, messageBot, returnBotMessageOutcome } from "./bot-messages.js"; import { findBotSecret, @@ -1363,6 +1370,12 @@ export function createRunExecutor(deps: ExecutorDeps) { await deps.jobs .enqueue(runContinueJob(failed.continuationRunId)) .catch((error) => getLogger().error("steering continuation enqueue", error)); + } else { + await continueAutopilotAfterRun(deps, run, { + checkpointed: false, + failed: true, + error: MISSING_MODEL_MESSAGE, + }).catch((continueError) => getLogger().error("autopilot continuation", continueError)); } if (run.trigger === "bot_message") { await returnBotMessageOutcome( @@ -1595,6 +1608,8 @@ export function createRunExecutor(deps: ExecutorDeps) { // Rehydrate from this run's prior progress rows so a resume after ask/takeover // still knows progress was already published (skip hollow finals; status outcome). let publishedMidTurnUserMessage = false; + // An autopilot run that reported a step; without one the autopilot counts as idle. + let autopilotCheckpointed = false; const midTurnUserTexts: string[] = []; let midTurnProgressCount = 0; { @@ -2142,6 +2157,38 @@ export function createRunExecutor(deps: ExecutorDeps) { }; const requestApproval = async () => { + // Nobody is there to approve during an autopilot: park the exact call as a + // suggestion for the user and keep working on other things. + if (run.trigger === "autopilot" && applied && !requiresMandatoryApproval) { + const parked = await createSuggestionFromTool(deps, { + run: { ...run, threadId: thread.id }, + bot, + executionId, + exposedToolNames, + args: JSON.parse( + redactSecrets( + JSON.stringify({ + tool: name, + args, + why: reviewReason ?? "Needs your approval", + }), + runSecrets, + ), + ) as Record, + }); + const result = + "ok" in parked + ? { + parked: true, + note: "This needs the user's approval and waits for them as a suggestion. Do not retry it; continue with other work.", + } + : parked; + await deps.prisma.externalEffect.updateMany({ + where: { id: applied.effect.id, status: "intended" }, + data: { status: "completed", result: result as never }, + }); + return result; + } if (!(await renewRunLease(deps, runId, workerId, fence))) { // Another worker owns the run now; exit without leaving a local pause card. return pauseForApproval(); @@ -3506,6 +3553,54 @@ export function createRunExecutor(deps: ExecutorDeps) { autoSpeak: updated.autoSpeak, }); } + if (name.startsWith("autopilot_")) { + const owner = { + spaceId: run.spaceId, + userId: run.userId, + botId: bot.id, + threadId: thread.id, + }; + const text = (key: string) => + typeof args[key] === "string" ? redactSecrets(String(args[key]), runSecrets) : ""; + if (name === "autopilot_start") { + const until = text("until") ? new Date(text("until")) : undefined; + if (until && !Number.isFinite(until.getTime())) { + return finish({ error: "until must be an ISO datetime." }); + } + const started = await startAutopilot(deps, owner, { + goal: text("goal"), + focus: text("focus") || undefined, + until, + allow: Array.isArray(args.allow) ? args.allow.map(String) : [], + }); + return finish( + "error" in started + ? started + : { + ok: true, + note: "The autopilot is on. Work on the first step now and report it with autopilot_checkpoint.", + }, + ); + } + if (name === "autopilot_checkpoint") { + const checkpoint = await checkpointAutopilot(deps.prisma, owner, { + summary: text("summary"), + next: text("next") || undefined, + }); + if ("ok" in checkpoint) autopilotCheckpointed = true; + return finish(checkpoint); + } + const open = await findOpenAutopilot(deps.prisma, owner); + if (!open) return finish({ error: "No autopilot is running in this thread." }); + const ended = await endAutopilot( + deps, + open.id, + name === "autopilot_finish" + ? { status: "finished", result: text("result") } + : { status: "stopped" }, + ); + return finish(ended ? { ok: true } : { error: "The autopilot already ended." }); + } if (name === "suggest") { const suggested = await createSuggestionFromTool(deps, { run: { ...run, threadId: thread.id }, @@ -4503,13 +4598,15 @@ export function createRunExecutor(deps: ExecutorDeps) { allowSilentPeerMessage || messagingChannelRun || publishedMidTurnUserMessage || - Boolean(acceptedSuggestion), + Boolean(acceptedSuggestion) || + run.trigger === "autopilot", emptyResponseText, suppressOutput: handedOff, skipEmptyFallback: publishedTerminalSubagent || publishedMidTurnUserMessage || - Boolean(acceptedSuggestion), + Boolean(acceptedSuggestion) || + run.trigger === "autopilot", }); } const blocks = handedOff @@ -4547,6 +4644,11 @@ export function createRunExecutor(deps: ExecutorDeps) { await deps.jobs .enqueue(runContinueJob(completed.continuationRunId)) .catch((error) => getLogger().error("steering continuation enqueue", error)); + } else { + await continueAutopilotAfterRun(deps, run, { + checkpointed: autopilotCheckpointed, + failed: false, + }).catch((error) => getLogger().error("autopilot continuation", error)); } if (botMessageOutcome) { // Prefer the final reply. If the turn only posted mid-turn progress, return that @@ -4619,6 +4721,12 @@ export function createRunExecutor(deps: ExecutorDeps) { await deps.jobs .enqueue(runContinueJob(failed.continuationRunId)) .catch((error) => getLogger().error("steering continuation enqueue", error)); + } else { + await continueAutopilotAfterRun(deps, run, { + checkpointed: autopilotCheckpointed, + failed: true, + error: message, + }).catch((continueError) => getLogger().error("autopilot continuation", continueError)); } if (run.trigger === "bot_message") { await returnBotMessageOutcome( diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 759f239..62c10ce 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -4,6 +4,7 @@ export * from "./ai-consent.js"; export * from "./artifact-files.js"; export * from "./artifacts.js"; export * from "./auto-review.js"; +export * from "./autopilot.js"; export * from "./background-job-handlers.js"; export * from "./bot-messages.js"; export * from "./box-emulator.js"; diff --git a/packages/adapters/src/schedule-tools.ts b/packages/adapters/src/schedule-tools.ts index 53525c3..047a28d 100644 --- a/packages/adapters/src/schedule-tools.ts +++ b/packages/adapters/src/schedule-tools.ts @@ -36,11 +36,20 @@ export function withScheduleTargetBot(tool: ConnectorTool): ConnectorTool { }; } +/** Tools only an autopilot's own runs report through. */ +const AUTOPILOT_RUN_TOOLS = new Set(["autopilot_checkpoint", "autopilot_finish"]); + export function filterBuiltinToolsForRun( tools: T[], runTrigger: string, ): T[] { - return runTrigger === "routine" ? tools.filter((tool) => tool.name !== "schedule_create") : tools; + return tools.filter((tool) => { + if (tool.name === "schedule_create") return runTrigger !== "routine"; + if (AUTOPILOT_RUN_TOOLS.has(tool.name)) return runTrigger === "autopilot"; + // Only the user starts an autopilot, never a bot, a routine or a webhook. + if (tool.name === "autopilot_start") return runTrigger === "user" || runTrigger === "autopilot"; + return true; + }); } /** Keep cross-bot messaging thread-specific while exposing schedules in DMs and groups. */ diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index d4c039c..c33bbad 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -275,6 +275,52 @@ export function inferScript( }, ]; } + // An autopilot's own runs: five steps, each reported, then the result. + const autopilot = /^Autopilot:.*\nOrder: (.*)$/m.exec(prompt); + if (autopilot) { + const steps = Number(/^Steps so far: (\d+)$/m.exec(prompt)?.[1] ?? 0); + if (/idle/i.test(autopilot[1] ?? "")) { + return [{ assistant: "still looking.", complete: true }]; + } + const step = steps + 1; + return [ + { + assistant: "", + toolCalls: [ + { + name: "autopilot_checkpoint", + args: { summary: `Ticket ${step} tidied`, next: `Ticket ${step + 1}` }, + }, + ...(step >= 5 + ? [ + { + name: "autopilot_finish", + args: { result: "Tidied 5 tickets; nothing waits on you." }, + }, + ] + : []), + ], + complete: true, + }, + ]; + } + if (lower.includes("work through the night")) { + return [ + { + assistant: "on it. i will keep going until the tickets are tidy.", + toolCalls: [ + { + name: "autopilot_start", + args: { + goal: lower.includes("idle") ? "Idle check" : "Tidy up the tickets", + focus: "Only tidy, change nothing else", + }, + }, + ], + complete: true, + }, + ]; + } // An idea for the user: nothing runs until they accept it. if (lower.includes("i should move more")) { return [ diff --git a/packages/contracts/src/autopilot.ts b/packages/contracts/src/autopilot.ts new file mode 100644 index 0000000..e6b1bab --- /dev/null +++ b/packages/contracts/src/autopilot.ts @@ -0,0 +1,63 @@ +import * as z from "zod"; +import { Id } from "./ids.js"; + +/** Continuations without a checkpoint, or failed runs in a row, before the autopilot pauses. */ +export const AUTOPILOT_IDLE_LIMIT = 3; +export const AUTOPILOT_FAIL_LIMIT = 3; + +export const AUTOPILOT_LIMITS = { goal: 500, focus: 200, checkpoint: 300, result: 2000 } as const; + +export const AutopilotStatusSchema = z.enum(["active", "paused", "finished", "stopped"]); +export type AutopilotStatus = z.infer; + +/** + * A quiet timeline line for an autopilot: when it started (with its goal, end time, focus and + * the approvals granted only for it), paused, stopped or finished. `ended` is set on the start + * line once the autopilot is over, so its Stop control goes away. + */ +export const AutopilotBlockSchema = z.object({ + kind: z.literal("autopilot"), + event: z.enum([ + "autopilot.started", + "autopilot.paused", + "autopilot.stopped", + "autopilot.finished", + ]), + autopilotId: Id, + goal: z.string(), + focus: z.string().optional(), + until: z.string().optional(), + /** Tools or apps the bot may use without asking while this autopilot runs. */ + allowed: z.array(z.string()).optional(), + steps: z.number().int().nonnegative().optional(), + durationMs: z.number().int().nonnegative().optional(), + /** Why it paused, or what it achieved. */ + text: z.string().optional(), + ended: z.boolean().optional(), +}); +export type AutopilotBlock = z.infer; + +export const AutopilotSchema = z.object({ + id: Id, + botId: Id, + threadId: Id, + goal: z.string(), + focus: z.string().nullable(), + until: z.string().nullable(), + status: AutopilotStatusSchema, + pauseReason: z.string().nullable(), + lastCheckpoint: z.string().nullable(), + nextStep: z.string().nullable(), + result: z.string().nullable(), + stepCount: z.number().int(), + startedAt: z.string(), + endedAt: z.string().nullable(), +}); +export type Autopilot = z.infer; + +export const AutopilotStartInputSchema = z.object({ + goal: z.string().trim().min(1).max(AUTOPILOT_LIMITS.goal), + focus: z.string().trim().max(AUTOPILOT_LIMITS.focus).optional(), + /** ISO time to stop at; without it the autopilot runs until the goal is done. */ + until: z.string().datetime({ offset: true }).optional(), +}); diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 49c247c..50253cf 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -930,6 +930,7 @@ export const RunSchema = z.object({ "delegation", "media", "suggestion", + "autopilot", ]), routineId: Id.nullable(), modelProvider: z.string().nullable(), diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 517ef60..06ba993 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -1,4 +1,5 @@ import * as z from "zod"; +import { AutopilotBlockSchema } from "./autopilot.js"; import { BotSecretDestination } from "./bot-secrets.js"; import { CardBlockSchema } from "./cards.js"; import { Id } from "./ids.js"; @@ -199,6 +200,7 @@ export const MessageBlock = z.discriminatedUnion("kind", [ ChoiceBlockSchema, IntakeNoteBlockSchema, RoutineEventBlockSchema, + AutopilotBlockSchema, z.object({ /** Inline app authorization card (Composio-backed): logo, name, one-line description, and an Authorize button that flips to connected. */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7b3b427..9f1ea52 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,5 +1,6 @@ export * from "./ai-consent.js"; export * from "./attachments.js"; +export * from "./autopilot.js"; export * from "./bot-secrets.js"; export * from "./cards.js"; export * from "./clock-time.js"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fff2504..d8fdac2 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,7 @@ 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 { AutopilotSchema, AutopilotStartInputSchema } from "./autopilot.js"; import { CardActSchema } from "./cards.js"; import { CheckInSchema, @@ -939,6 +940,15 @@ export const appContract = { inbox: { list: oc.output(InboxListOutputSchema), }, + /** The thread's open autopilot: start one, stop it, or let a paused one go on. */ + autopilot: { + get: oc.input(threadTarget).output(AutopilotSchema.nullable()), + start: oc + .input(threadTarget.safeExtend(AutopilotStartInputSchema.shape)) + .output(AutopilotSchema), + stop: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })), + resume: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })), + }, voice: { catalog: oc.output(z.array(VoiceCatalogEntrySchema)), status: oc.output(VoiceStatusSchema), diff --git a/packages/contracts/src/runs.ts b/packages/contracts/src/runs.ts index 6244ebb..666c2d3 100644 --- a/packages/contracts/src/runs.ts +++ b/packages/contracts/src/runs.ts @@ -24,6 +24,7 @@ export const RunActivityRowSchema = z.object({ "delegation", "media", "suggestion", + "autopilot", ]), notificationsEnabled: z.boolean(), promptSnippet: z.string(), diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts index 78fcde3..4aea966 100644 --- a/packages/core/src/attachments.ts +++ b/packages/core/src/attachments.ts @@ -6,6 +6,7 @@ import { isAllowedAttachmentMimeType, isAttachmentImageMimeType, } from "@rakazo/contracts"; +import { autopilotHistoryText } from "./autopilot.js"; import { cardHistoryText } from "./cards.js"; import { routineEventHistoryText } from "./routine-events.js"; @@ -140,6 +141,7 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string { return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`; } if (block.kind === "event") return routineEventHistoryText(block); + if (block.kind === "autopilot") return autopilotHistoryText(block); if (block.kind === "card") return cardHistoryText(block); if (block.kind === "handoff") { return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`; diff --git a/packages/core/src/autopilot.test.ts b/packages/core/src/autopilot.test.ts new file mode 100644 index 0000000..8afcdc7 --- /dev/null +++ b/packages/core/src/autopilot.test.ts @@ -0,0 +1,85 @@ +import type { AutopilotBlock } from "@rakazo/contracts"; +import { describe, expect, it } from "vitest"; +import { + advanceAutopilotGuard, + autopilotContinuationPrompt, + autopilotExpired, + autopilotHistoryText, + formatAutopilotDuration, + markAutopilotLinesEnded, +} from "./autopilot.js"; + +const started: AutopilotBlock = { + kind: "autopilot", + event: "autopilot.started", + autopilotId: "a1", + goal: "Tidy up the tickets", + focus: "only tidy", + until: "2026-10-05T05:00:00.000Z", +}; + +describe("autopilot", () => { + it("pauses after three runs without a checkpoint or three failures, not before", () => { + let counts = { idleCount: 0, failCount: 0 }; + for (const expected of [null, null, "idle"]) { + const next = advanceAutopilotGuard(counts, { checkpointed: false, failed: false }); + expect(next.pause).toBe(expected); + counts = next; + } + expect(advanceAutopilotGuard(counts, { checkpointed: true, failed: false })).toEqual({ + idleCount: 0, + failCount: 0, + pause: null, + }); + expect( + advanceAutopilotGuard({ idleCount: 0, failCount: 2 }, { checkpointed: true, failed: true }) + .pause, + ).toBe("failing"); + }); + + it("tells each continuation the order and where it stands", () => { + const prompt = autopilotContinuationPrompt({ + goal: "Tidy up the tickets", + focus: "only tidy", + until: null, + lastCheckpoint: "Ticket 2 tidied", + nextStep: "Ticket 3", + stepCount: 2, + }); + expect(prompt).toMatch(/^Autopilot:/); + expect(prompt).toContain("Order: Tidy up the tickets"); + expect(prompt).toContain("Until: done"); + expect(prompt).toContain("Steps so far: 2"); + expect(prompt).toContain("Last checkpoint: Ticket 2 tidied"); + }); + + it("ends at its time", () => { + expect(autopilotExpired(null)).toBe(false); + expect(autopilotExpired(new Date(1000), 2000)).toBe(true); + expect(autopilotExpired(new Date(3000), 2000)).toBe(false); + }); + + it("reads as a line in the model history and loses its controls once over", () => { + expect(autopilotHistoryText(started)).toBe( + "[autopilot on: Tidy up the tickets — until 2026-10-05T05:00:00.000Z — focus: only tidy]", + ); + const ended = markAutopilotLinesEnded([started, { kind: "text" }], "a1"); + expect(ended?.[0]).toMatchObject({ ended: true }); + expect(markAutopilotLinesEnded(ended ?? [], "a1")).toBeNull(); + expect( + autopilotHistoryText({ + ...started, + event: "autopilot.finished", + steps: 5, + text: "Tidied 5 tickets", + }), + ).toBe("[autopilot finished after 5 steps: Tidied 5 tickets]"); + }); + + it("words durations briefly", () => { + expect(formatAutopilotDuration(10_000)).toBe("1 min"); + expect(formatAutopilotDuration(12 * 60_000)).toBe("12 min"); + expect(formatAutopilotDuration(222 * 60_000)).toBe("3 h 42 min"); + expect(formatAutopilotDuration(120 * 60_000)).toBe("2 h"); + }); +}); diff --git a/packages/core/src/autopilot.ts b/packages/core/src/autopilot.ts new file mode 100644 index 0000000..a12df36 --- /dev/null +++ b/packages/core/src/autopilot.ts @@ -0,0 +1,96 @@ +import type { AutopilotBlock } from "@rakazo/contracts"; +import { AUTOPILOT_FAIL_LIMIT, AUTOPILOT_IDLE_LIMIT } from "@rakazo/contracts"; + +/** What the autopilot remembers between runs. */ +export interface AutopilotState { + goal: string; + focus: string | null; + until: Date | null; + lastCheckpoint: string | null; + nextStep: string | null; + stepCount: number; +} + +/** + * The prompt of each continuation run: the order in the user's words and where the work stands, + * so a fresh run picks up without the whole history. + */ +export function autopilotContinuationPrompt(state: AutopilotState, now = new Date()): string { + return [ + "Autopilot: continue the user's order on your own, one sensible step at a time.", + `Order: ${state.goal}`, + state.focus ? `Focus: ${state.focus}` : null, + state.until ? `Until: ${state.until.toISOString()} (now ${now.toISOString()})` : "Until: done", + `Steps so far: ${state.stepCount}`, + state.lastCheckpoint ? `Last checkpoint: ${state.lastCheckpoint}` : null, + state.nextStep ? `Planned next: ${state.nextStep}` : null, + "Do the next step, then call autopilot_checkpoint with what changed. When the order is done, call autopilot_finish with the result. Actions that need the user's approval are parked as suggestions; continue with other work.", + ] + .filter(Boolean) + .join("\n"); +} + +/** Whether the run that just ended moved the autopilot on, and if it should pause now. */ +export function advanceAutopilotGuard( + counts: { idleCount: number; failCount: number }, + run: { checkpointed: boolean; failed: boolean }, +): { idleCount: number; failCount: number; pause: "idle" | "failing" | null } { + const idleCount = run.checkpointed ? 0 : counts.idleCount + 1; + const failCount = run.failed ? counts.failCount + 1 : 0; + const pause = + failCount >= AUTOPILOT_FAIL_LIMIT + ? "failing" + : idleCount >= AUTOPILOT_IDLE_LIMIT + ? "idle" + : null; + return { idleCount, failCount, pause }; +} + +/** The autopilot's time is up. */ +export function autopilotExpired(until: Date | null, now = Date.now()): boolean { + return Boolean(until && until.getTime() <= now); +} + +/** How a later turn reads an autopilot line. */ +export function autopilotHistoryText(block: AutopilotBlock): string { + switch (block.event) { + case "autopilot.started": { + const parts = [block.goal]; + if (block.until) parts.push(`until ${block.until}`); + if (block.focus) parts.push(`focus: ${block.focus}`); + if (block.allowed?.length) parts.push(`allowed without asking: ${block.allowed.join(", ")}`); + return `[autopilot ${block.ended ? "was on" : "on"}: ${parts.join(" — ")}]`; + } + case "autopilot.paused": + return `[autopilot paused: ${block.text ?? "no progress"}]`; + case "autopilot.stopped": + return "[autopilot stopped by the user]"; + case "autopilot.finished": + return `[autopilot finished after ${block.steps ?? 0} steps: ${block.text ?? ""}]`; + } +} + +/** The blocks with the start line of `autopilotId` marked ended, or null when nothing changes. */ +export function markAutopilotLinesEnded( + blocks: readonly { kind: string }[], + autopilotId: string, +): AutopilotBlock[] | null { + let changed = false; + const next = blocks.map((block) => { + const line = block as AutopilotBlock; + if (line.kind !== "autopilot" || line.autopilotId !== autopilotId || line.ended) return line; + if (line.event !== "autopilot.started" && line.event !== "autopilot.paused") return line; + changed = true; + return { ...line, ended: true }; + }); + return changed ? next : null; +} + +/** "3 h 42 min", "12 min", or "1 min" for anything shorter. */ +export function formatAutopilotDuration(ms: number): string { + const minutes = Math.max(1, Math.round(ms / 60_000)); + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (!hours) return `${minutes} min`; + return rest ? `${hours} h ${rest} min` : `${hours} h`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e05968a..78addf4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export * from "./ai-consent.js"; export * from "./answerable-ask.js"; export * from "./async.js"; export * from "./attachments.js"; +export * from "./autopilot.js"; export * from "./bot-messages.js"; export * from "./bot-reply-sanitize.js"; export * from "./bot-sections.js"; diff --git a/packages/core/src/routine-events.ts b/packages/core/src/routine-events.ts index 4a521e0..bb9bd29 100644 --- a/packages/core/src/routine-events.ts +++ b/packages/core/src/routine-events.ts @@ -3,7 +3,10 @@ import { formatCron, isOneShotRoutineCrons } from "./cron.js"; /** A message that is only timeline lines, shown without a speaker, bubble or reactions. */ export function isRoutineEventMessage(blocks: readonly MessageBlock[]): boolean { - return blocks.length > 0 && blocks.every((block) => block.kind === "event"); + return ( + blocks.length > 0 && + blocks.every((block) => block.kind === "event" || block.kind === "autopilot") + ); } /** The blocks with every line about `routineId` marked removed, or null when nothing changes. */ diff --git a/packages/db/prisma/migrations/20261004180000_autopilot/migration.sql b/packages/db/prisma/migrations/20261004180000_autopilot/migration.sql new file mode 100644 index 0000000..ae787cc --- /dev/null +++ b/packages/db/prisma/migrations/20261004180000_autopilot/migration.sql @@ -0,0 +1,38 @@ +-- A standing order the bot keeps working on run after run, and the approvals granted only for it. +CREATE TABLE "autopilots" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "botId" TEXT NOT NULL, + "threadId" TEXT NOT NULL, + "goal" TEXT NOT NULL, + "focus" TEXT, + "until" TIMESTAMP(3), + "status" TEXT NOT NULL, + "pauseReason" TEXT, + "lastCheckpoint" TEXT, + "nextStep" TEXT, + "result" TEXT, + "stepCount" INTEGER NOT NULL DEFAULT 0, + "idleCount" INTEGER NOT NULL DEFAULT 0, + "failCount" INTEGER NOT NULL DEFAULT 0, + "continuations" INTEGER NOT NULL DEFAULT 0, + "startMessageId" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "endedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "autopilots_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "autopilots_spaceId_userId_status_idx" ON "autopilots"("spaceId", "userId", "status"); +CREATE INDEX "autopilots_threadId_status_idx" ON "autopilots"("threadId", "status"); + +ALTER TABLE "autopilots" ADD CONSTRAINT "autopilots_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "autopilots" ADD CONSTRAINT "autopilots_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "autopilots" ADD CONSTRAINT "autopilots_botId_fkey" FOREIGN KEY ("botId") REFERENCES "bots"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "autopilots" ADD CONSTRAINT "autopilots_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "threads"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "action_approval_rules" ADD COLUMN "autopilotId" TEXT; +ALTER TABLE "action_approval_rules" ADD CONSTRAINT "action_approval_rules_autopilotId_fkey" FOREIGN KEY ("autopilotId") REFERENCES "autopilots"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8ed7fbd..2a70125 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -41,6 +41,7 @@ model User { mcpServers McpServer[] botSections BotSection[] actionApprovalRules ActionApprovalRule[] + autopilots Autopilot[] actionAutoReviewPreferences ActionAutoReviewPreference[] createdSpaces Space[] @relation("SpaceCreator") botSecrets BotSecret[] @@ -132,6 +133,9 @@ model ActionApprovalRule { effect String matchKind String matchValue String + /// Set when the rule was granted for one autopilot; it ends with it. + autopilotId String? + autopilot Autopilot? @relation(fields: [autopilotId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@unique([spaceId, createdByUserId, effect, matchKind, matchValue]) @@ -194,6 +198,7 @@ model Space { tasks Task[] runs Run[] routines Routine[] + autopilots Autopilot[] scratchpadItems ScratchpadItem[] projectGoals ProjectGoal[] taughtSkills TaughtSkill[] @@ -418,6 +423,7 @@ model Bot { allowedBuiltinTools String[] @default([]) modelTier String? routines Routine[] + autopilots Autopilot[] scratchpadItems ScratchpadItem[] taughtSkills TaughtSkill[] memoryDocuments MemoryDocument[] @@ -542,6 +548,7 @@ model Thread { tasks Task[] runs Run[] routines Routine[] + autopilots Autopilot[] delegatedTasks DelegatedTask[] @@index([spaceId]) @@ -757,6 +764,45 @@ model ExternalEffect { @@map("external_effects") } +/// A standing order in the user's words that the bot works on run after run, until it is done, +/// the chosen time is up or the user stops it. At most one per user and space is open at a time. +model Autopilot { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + botId String + bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) + threadId String + thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade) + goal String + focus String? + until DateTime? + /// active, paused, finished or stopped + status String + pauseReason String? + lastCheckpoint String? + nextStep String? + result String? + stepCount Int @default(0) + /// Continuations since the last checkpoint, and failed runs in a row. + idleCount Int @default(0) + failCount Int @default(0) + /// Continuation runs started; also keys each one so it starts once. + continuations Int @default(0) + startMessageId String? + startedAt DateTime @default(now()) + endedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + approvalRules ActionApprovalRule[] + + @@index([spaceId, userId, status]) + @@index([threadId, status]) + @@map("autopilots") +} + model Routine { id String @id @default(cuid()) spaceId String diff --git a/packages/db/src/autopilot-lines.ts b/packages/db/src/autopilot-lines.ts new file mode 100644 index 0000000..c22d572 --- /dev/null +++ b/packages/db/src/autopilot-lines.ts @@ -0,0 +1,59 @@ +import type { AutopilotBlock, MessageBlock } from "@rakazo/contracts"; +import { markAutopilotLinesEnded } from "@rakazo/core"; +import type { Prisma } from "./client.js"; +import { appendEventInTransaction } from "./events.js"; +import { createThreadMessageInTransaction } from "./messages.js"; + +/** Write one autopilot line in the thread; it never marks the thread unread. */ +export async function postAutopilotLineInTransaction( + tx: Prisma.TransactionClient, + input: { spaceId: string; threadId: string; botId: string; block: AutopilotBlock }, +): Promise<{ messageId: string; seq: number }> { + const message = await createThreadMessageInTransaction(tx, { + threadId: input.threadId, + role: "bot", + blocks: [input.block], + botId: input.botId, + markUnread: false, + }); + const event = await appendEventInTransaction(tx, { + spaceId: input.spaceId, + threadId: input.threadId, + botId: input.botId, + type: "thread.message.created", + payload: { messageId: message.id, role: "bot", blocks: [input.block] }, + }); + return { messageId: message.id, seq: event.seq }; +} + +/** Take the Stop and Continue controls off an autopilot's earlier lines once it is over. */ +export async function endAutopilotLinesInTransaction( + tx: Prisma.TransactionClient, + input: { spaceId: string; threadId: string; autopilotId: string }, +): Promise { + const messages = await tx.message.findMany({ + where: { + threadId: input.threadId, + blocks: { array_contains: [{ kind: "autopilot", autopilotId: input.autopilotId }] }, + }, + select: { id: true, role: true, botId: true, blocks: true }, + }); + let seq: number | null = null; + for (const message of messages) { + const blocks = markAutopilotLinesEnded(message.blocks as MessageBlock[], input.autopilotId); + if (!blocks || !message.botId) continue; + await tx.message.update({ + where: { id: message.id }, + data: { blocks: blocks as Prisma.InputJsonValue }, + }); + const event = await appendEventInTransaction(tx, { + spaceId: input.spaceId, + threadId: input.threadId, + botId: message.botId, + type: "thread.message.updated", + payload: { messageId: message.id, role: message.role, blocks }, + }); + seq = event.seq; + } + return seq; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 910d8b6..5e4e200 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,3 +1,4 @@ +export * from "./autopilot-lines.js"; export * from "./bootstrap-user.js"; export * from "./cancel-runs.js"; export * from "./card-messages.js";