evOo-local/apps/api/src/taught-skills.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

573 lines
19 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { ORPCError } from "@orpc/server";
import type { AgentHomeStore, JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
import {
type AdapterContext,
runContinueJob,
skillTeachingExpireJob,
skillTeachingExpireJobKey,
} from "@rakazo/adapter-kit";
import {
acquireComputerExecutionLease,
appendRecordingEvent,
ComputerBusyError,
type ComputerExecutionLease,
captureTeachingSnapshot,
completeTeachingSession,
emptyRecording,
expireTaughtSkillTeaching,
extendActiveComputerControl,
getActiveTeachingSession,
mapTaughtSkill,
observeStopSnapshot,
parsePlaybook,
parseRecording,
provisionComputer,
recordTeachingInputEvent,
releaseComputerExecutionLease,
releaseTeachingComputerControlForBot,
scheduleComputerControlExpiry,
screenLeaseIdForRun,
type TeachComputerInput,
teachingControlLeaseExpiresAt,
} from "@rakazo/adapters";
import type { Actor, MessageBlock, TaughtSkill } from "@rakazo/contracts";
import {
ACTIVE_RUN_STATUSES,
buildPlaybookFromRecording,
formatSkillRunPrompt,
type SkillPlaybook,
type TeachRecordingEvent,
teachRecordingTtlMs,
} from "@rakazo/core";
import {
type createRepos,
expireComputerExecutionLeases,
IsolationError,
type PrismaClient,
type ThreadEvents,
} from "@rakazo/db";
type TaughtSkillRow = {
id: string;
spaceId: string;
botId: string;
userId: string;
name: string;
goal: string;
status: string;
playbook: unknown;
recording: unknown;
startedAt: Date | null;
expiresAt: Date | null;
stoppedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export interface TaughtSkillsDeps {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
sandbox: SandboxProvider;
home: AgentHomeStore;
dataDir: string;
}
function computerContext(actor: Actor, botId: string, operationId: string): AdapterContext {
return {
operationId,
traceId: operationId,
spaceId: actor.spaceId,
userId: actor.userId,
botId,
signal: new AbortController().signal,
};
}
function ownedSkillWhere(actor: Actor, skillId: string) {
return { id: skillId, spaceId: actor.spaceId, userId: actor.userId };
}
async function getOwnedSkill(
deps: TaughtSkillsDeps,
actor: Actor,
skillId: string,
): Promise<TaughtSkillRow> {
const skill = await deps.prisma.taughtSkill.findFirst({ where: ownedSkillWhere(actor, skillId) });
if (!skill) throw new IsolationError();
return skill;
}
export async function assertTeachingSendAllowed(
prisma: PrismaClient,
spaceId: string,
botId: string,
): Promise<void> {
const active = await getActiveTeachingSession(prisma, spaceId, botId);
if (active) {
throw new ORPCError("CONFLICT", { message: "Stop teaching first" });
}
}
async function cancelActiveRuns(
deps: TaughtSkillsDeps,
_actor: Actor,
botId: string,
): Promise<void> {
const activeRuns = await deps.prisma.run.findMany({
where: { botId, status: { in: [...ACTIVE_RUN_STATUSES] } },
select: { id: true },
});
await deps.prisma.run.updateMany({
where: { botId, status: { in: [...ACTIVE_RUN_STATUSES] } },
data: { status: "cancelled", completedAt: new Date() },
});
await expireComputerExecutionLeases(deps.prisma, { botId });
await deps.prisma.computer.updateMany({
where: { executionBotId: botId },
data: { executionRunId: null, executionBotId: null, executionLeaseExpiresAt: null },
});
await deps.prisma.event.deleteMany({
where: { type: "thread.progress", runId: { in: activeRuns.map((run) => run.id) } },
});
}
async function ensureGraphicalComputer(
deps: TaughtSkillsDeps,
actor: Actor,
bot: Awaited<ReturnType<ReturnType<typeof createRepos>["getBot"]>>,
) {
if (bot.computer?.kind === "desktop") {
throw new ORPCError("BAD_REQUEST", {
message: "Teaching needs a graphical sandbox computer, not a desktop host",
});
}
if (!bot.computer) throw new IsolationError();
if (bot.computer.state !== "running" || !bot.computer.providerRef) {
const ctx = computerContext(actor, bot.id, "skills.start");
const manualRunId = `teach:${randomUUID()}`;
let lease: ComputerExecutionLease | null;
try {
lease = await acquireComputerExecutionLease(deps.prisma, {
computerId: bot.computer.id,
runId: manualRunId,
botId: bot.id,
});
} catch (error) {
if (error instanceof ComputerBusyError) {
throw new ORPCError("CONFLICT", { message: "Computer is busy" });
}
throw error;
}
try {
await provisionComputer(deps, bot.computer.id, {
...ctx,
screenLeaseId: screenLeaseIdForRun(lease, manualRunId),
});
} catch (error) {
if (error instanceof ComputerBusyError) {
throw new ORPCError("CONFLICT", { message: "Computer is busy" });
}
throw error;
} finally {
await releaseComputerExecutionLease(deps.prisma, lease);
}
bot = await deps.prisma.bot.findUniqueOrThrow({
where: { id: bot.id },
include: { thread: true, computer: true },
});
}
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "Computer must be running to teach" });
}
if (bot.computer.kind === "desktop") {
throw new ORPCError("BAD_REQUEST", {
message: "Teaching needs a graphical sandbox computer, not a desktop host",
});
}
return bot;
}
async function grantTakeover(
deps: TaughtSkillsDeps,
actor: Actor,
bot: Awaited<ReturnType<ReturnType<typeof createRepos>["getBot"]>>,
until: Date,
): Promise<{ bot: typeof bot; leaseId: string }> {
if (!bot.computer) throw new IsolationError();
if (await extendActiveComputerControl(deps.prisma, deps.jobs, bot.computer, bot.id, until)) {
const leaseId = bot.computer.controlLeaseId;
if (!leaseId) throw new IsolationError();
return { bot, leaseId };
}
const leaseId = randomUUID();
const expiresAt = teachingControlLeaseExpiresAt(until);
const granted = await deps.prisma.computer.updateMany({
where: {
id: bot.computer.id,
state: "running",
OR: [{ controlHolder: { not: "user" } }, { controlBotId: bot.id }],
},
data: {
controlHolder: "user",
controlLeaseId: leaseId,
controlLeaseExpiresAt: expiresAt,
controlBotId: bot.id,
state: "running",
},
});
if (granted.count !== 1) {
throw new ORPCError("CONFLICT", { message: "Could not take control of the computer" });
}
await scheduleComputerControlExpiry(deps.jobs, bot.computer.id, leaseId, expiresAt);
if (bot.thread) {
await deps.events.append({
spaceId: actor.spaceId,
threadId: bot.thread.id,
botId: bot.id,
type: "computer.takeover.granted",
payload: { holder: "user", reason: "teaching" },
});
}
return { bot, leaseId };
}
async function updateSkillDraftMessage(
deps: TaughtSkillsDeps,
actor: Actor,
skill: TaughtSkillRow,
input: {
name?: string;
playbook?: SkillPlaybook;
status?: "draft" | "saved";
},
): Promise<void> {
const bot = await deps.prisma.bot.findUnique({
where: { id: skill.botId },
include: { thread: true },
});
if (!bot?.thread) return;
const messages = await deps.prisma.message.findMany({
where: { threadId: bot.thread.id, role: "bot" },
orderBy: { seq: "desc" },
take: 100,
});
for (const message of messages) {
const parsed = message.blocks as MessageBlock[];
if (!Array.isArray(parsed)) continue;
const index = parsed.findIndex(
(block) => block.kind === "skill_draft" && block.skillId === skill.id,
);
if (index === -1) continue;
const existing = parsed[index];
if (existing?.kind !== "skill_draft") continue;
const playbook = input.playbook ?? parsePlaybook(skill.playbook);
const nextBlocks: MessageBlock[] = [...parsed];
nextBlocks[index] = {
kind: "skill_draft",
skillId: skill.id,
name: input.name ?? existing.name,
goal: skill.goal,
playbook,
status: input.status ?? existing.status,
};
await deps.prisma.message.update({
where: { id: message.id },
data: { blocks: nextBlocks as never },
});
await deps.events.append({
spaceId: actor.spaceId,
threadId: bot.thread.id,
botId: bot.id,
type: "thread.message.updated",
payload: { messageId: message.id, role: "bot", blocks: nextBlocks },
});
return;
}
}
export async function expireTeachingSessionIfNeeded(deps: TaughtSkillsDeps, skillId: string) {
return expireTaughtSkillTeaching(deps, skillId);
}
export async function stopTeachingSession(
deps: TaughtSkillsDeps,
actor: Actor,
skillId: string,
): Promise<TaughtSkill> {
await getOwnedSkill(deps, actor, skillId);
await expireTeachingSessionIfNeeded(deps, skillId);
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
if (current.status === "draft" || current.status === "saved") {
await releaseTeachingComputerControlForBot(
deps,
actor,
current.botId,
parseRecording(current.recording).controlLeaseId,
);
return mapTaughtSkill(current);
}
if (current.status !== "recording" && current.status !== "drafting") {
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not active" });
}
const bot = await deps.prisma.bot.findUnique({
where: { id: current.botId },
include: { thread: true, computer: true },
});
if (!bot) throw new IsolationError();
const stopSnapshot =
current.status === "recording" && bot.computer?.providerRef
? await observeStopSnapshot(deps, actor, bot)
: undefined;
const finalized = await completeTeachingSession(deps, actor, skillId, "stopped", stopSnapshot);
await deps.jobs.cancel(skillTeachingExpireJobKey(current.id));
return mapTaughtSkill(finalized);
}
export function createTaughtSkillsService(deps: TaughtSkillsDeps) {
return {
async list(actor: Actor, botId: string): Promise<TaughtSkill[]> {
const rows = await deps.prisma.taughtSkill.findMany({
where: { spaceId: actor.spaceId, botId, userId: actor.userId },
orderBy: { updatedAt: "desc" },
});
return rows.map(mapTaughtSkill);
},
async get(actor: Actor, skillId: string): Promise<TaughtSkill> {
const row = await getOwnedSkill(deps, actor, skillId);
await expireTeachingSessionIfNeeded(deps, row.id);
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
return mapTaughtSkill(current);
},
async start(actor: Actor, botId: string, goal: string): Promise<TaughtSkill> {
let bot = await deps.prisma.bot.findFirst({
where: { id: botId, spaceId: actor.spaceId, userId: actor.userId },
include: { thread: true, computer: true },
});
if (!bot) throw new IsolationError();
const alreadyRecording = await deps.prisma.taughtSkill.findFirst({
where: { botId, status: "recording" },
select: { id: true },
});
if (alreadyRecording) {
throw new ORPCError("CONFLICT", { message: "A teaching session is already active" });
}
await cancelActiveRuns(deps, actor, botId);
bot = await ensureGraphicalComputer(deps, actor, bot);
const startedAt = new Date();
const expiresAt = new Date(startedAt.getTime() + teachRecordingTtlMs());
const { leaseId } = await grantTakeover(deps, actor, bot, expiresAt);
let row: TaughtSkillRow;
try {
row = await deps.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT id FROM bots WHERE id = ${botId} FOR UPDATE`;
const existing = await tx.taughtSkill.findFirst({
where: { botId, status: "recording" },
});
if (existing) {
throw new ORPCError("CONFLICT", { message: "A teaching session is already active" });
}
return tx.taughtSkill.create({
data: {
spaceId: actor.spaceId,
botId,
userId: actor.userId,
goal,
status: "recording",
startedAt,
expiresAt,
recording: { ...emptyRecording(), controlLeaseId: leaseId } as never,
playbook: buildPlaybookFromRecording(goal, []) as never,
},
});
});
} catch (error) {
if (error instanceof ORPCError) throw error;
if (
error instanceof Error &&
"code" in error &&
(error as { code?: string }).code === "P2002"
) {
throw new ORPCError("CONFLICT", { message: "A teaching session is already active" });
}
throw error;
}
await deps.jobs.enqueue(skillTeachingExpireJob(row.id, expiresAt));
const withSnapshot = await captureTeachingSnapshot(deps, actor, bot, row);
if (bot.thread) {
await deps.events.append({
spaceId: actor.spaceId,
threadId: bot.thread.id,
botId: bot.id,
type: "skill.teaching.started",
payload: { skillId: row.id, goal },
});
}
return mapTaughtSkill(withSnapshot);
},
async appendEvent(
actor: Actor,
skillId: string,
event: TeachRecordingEvent,
): Promise<TaughtSkill> {
await getOwnedSkill(deps, actor, skillId);
await expireTeachingSessionIfNeeded(deps, skillId);
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
if (current.status !== "recording") {
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not recording" });
}
const updated = await appendRecordingEvent(deps, skillId, event, { requireRecording: true });
if (updated.status !== "recording") {
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not recording" });
}
return mapTaughtSkill(updated);
},
async snapshot(actor: Actor, skillId: string): Promise<TaughtSkill> {
await getOwnedSkill(deps, actor, skillId);
await expireTeachingSessionIfNeeded(deps, skillId);
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
if (current.status !== "recording") {
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not recording" });
}
const bot = await deps.prisma.bot.findUnique({
where: { id: current.botId },
include: { computer: true },
});
if (!bot) throw new IsolationError();
const updated = await captureTeachingSnapshot(deps, actor, bot, current);
return mapTaughtSkill(updated);
},
stop: (actor: Actor, skillId: string) => stopTeachingSession(deps, actor, skillId),
async updateDraft(
actor: Actor,
skillId: string,
input: { name?: string; playbook: SkillPlaybook },
): Promise<TaughtSkill> {
const skill = await getOwnedSkill(deps, actor, skillId);
if (skill.status !== "draft" && skill.status !== "saved") {
throw new ORPCError("BAD_REQUEST", { message: "Skill is not editable yet" });
}
const row = await deps.prisma.taughtSkill.update({
where: { id: skill.id },
data: {
name: input.name ?? skill.name,
playbook: input.playbook as never,
status: skill.status === "saved" ? "saved" : "draft",
},
});
await updateSkillDraftMessage(deps, actor, row, {
name: row.name,
playbook: parsePlaybook(row.playbook),
status: row.status === "saved" ? "saved" : "draft",
});
return mapTaughtSkill(row);
},
async save(actor: Actor, skillId: string, name?: string): Promise<TaughtSkill> {
const skill = await getOwnedSkill(deps, actor, skillId);
if (skill.status !== "draft" && skill.status !== "saved") {
throw new ORPCError("BAD_REQUEST", { message: "Finish recording before saving" });
}
const row = await deps.prisma.taughtSkill.update({
where: { id: skill.id },
data: {
status: "saved",
name: name ?? (skill.name || skill.goal.slice(0, 80)),
},
});
const bot = await deps.prisma.bot.findUnique({
where: { id: row.botId },
include: { thread: true },
});
await updateSkillDraftMessage(deps, actor, row, {
name: row.name,
playbook: parsePlaybook(row.playbook),
status: "saved",
});
if (bot?.thread) {
await deps.events.append({
spaceId: actor.spaceId,
threadId: bot.thread.id,
botId: bot.id,
type: "skill.saved",
payload: { skillId: row.id, name: row.name },
});
}
return mapTaughtSkill(row);
},
async testRun(actor: Actor, skillId: string, prompt?: string): Promise<{ runId: string }> {
const skill = await getOwnedSkill(deps, actor, skillId);
if (skill.status !== "saved" && skill.status !== "draft") {
throw new ORPCError("BAD_REQUEST", { message: "Skill must be saved or drafted first" });
}
const bot = await deps.prisma.bot.findUnique({
where: { id: skill.botId },
include: { thread: true },
});
if (!bot?.thread) throw new IsolationError();
const playbook = parsePlaybook(skill.playbook);
const taskPrompt =
prompt ?? formatSkillRunPrompt(skill.name || skill.goal.slice(0, 80), playbook, true);
const task = await deps.prisma.task.create({
data: {
spaceId: actor.spaceId,
botId: bot.id,
threadId: bot.thread.id,
userId: actor.userId,
prompt: taskPrompt,
status: "queued",
},
});
const run = await deps.prisma.run.create({
data: {
spaceId: actor.spaceId,
botId: bot.id,
threadId: bot.thread.id,
taskId: task.id,
userId: actor.userId,
status: "queued",
trigger: "skill",
},
});
await deps.jobs.enqueue(runContinueJob(run.id));
return { runId: run.id };
},
async remove(actor: Actor, skillId: string): Promise<{ ok: true }> {
const skill = await getOwnedSkill(deps, actor, skillId);
if (skill.status === "recording") {
await deps.jobs.cancel(skillTeachingExpireJobKey(skill.id));
await releaseTeachingComputerControlForBot(
deps,
actor,
skill.botId,
parseRecording(skill.recording).controlLeaseId,
);
}
await deps.prisma.taughtSkill.delete({ where: { id: skill.id } });
return { ok: true as const };
},
expireTeachingSessionIfNeeded: (skillId: string) =>
expireTeachingSessionIfNeeded(deps, skillId),
async recordInput(
actor: Actor,
botId: string,
mapped: TeachComputerInput,
): Promise<"recorded" | "idle" | "stale"> {
return recordTeachingInputEvent(deps, actor, botId, mapped);
},
};
}