Add git, gh, openssh-client, curl, and sudo to the computer image with a docker smoke test. Mount deployment-owner project workspacePath at /home/rakazo/project, align /home/rakazo file paths with shell, and resolve default branch from origin/HEAD. Inject the owner GitHub token from encrypted deployment settings only into project computers; enforce branch-only push, block force-push, and require approval before the first push or gh pr in a run. Add runaway guards (tool fuse and stall pause), collapse live narration, fix streaming progress merge, DE run strings, and Lead as the project main Oo name. Fix evo-deploy test flake. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import type { RealtimeFanout } from "@rakazo/adapter-kit";
|
|
import type { UserFactCategory } from "@rakazo/contracts";
|
|
import type { PrismaClient } from "./client.js";
|
|
import { appendEvent } from "./events.js";
|
|
import { createRepos } from "./repos.js";
|
|
import { IsolationError, requireMembership } from "./scope.js";
|
|
import { lockSpaceForContentCreation } from "./spaces.js";
|
|
|
|
/** Idempotency key of the first bot; the main bot keeps it in its home space. */
|
|
export const MAIN_BOT_SPAWN_KEY = "onboarding:first";
|
|
|
|
/** Name and role of a freshly created main bot; onboarding lets the user rename it. */
|
|
export const MAIN_BOT_DEFAULTS = {
|
|
name: "Chief",
|
|
title: "Chief of Staff",
|
|
description: "Coordinates specialists — routes work, never a do-everything generalist.",
|
|
} as const;
|
|
|
|
/** Name and role of a project's main bot, the one the user talks to about that project. */
|
|
export const PROJECT_MAIN_BOT_DEFAULTS = {
|
|
name: "Lead",
|
|
title: "Project lead",
|
|
description: "Leads this project and hands its work to specialists.",
|
|
} as const;
|
|
|
|
/** Upper bound on facts handed to a run, so the user block stays small. */
|
|
export const USER_CONTEXT_FACT_LIMIT = 60;
|
|
|
|
export interface MainBotRef {
|
|
id: string;
|
|
spaceId: string;
|
|
threadId: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface MainConversation {
|
|
groupId: string;
|
|
threadId: string;
|
|
spaceId: string;
|
|
leadBotId: string;
|
|
}
|
|
|
|
/** What every run may know about the user: the profile plus active facts visible in a space. */
|
|
export interface UserContext {
|
|
name: string;
|
|
timeZone: string | null;
|
|
locale: string | null;
|
|
language: string | null;
|
|
communicationStyle: string;
|
|
summary: string;
|
|
facts: Array<{ category: UserFactCategory; value: string; confidence: number }>;
|
|
/** The main bot is still asking its onboarding questions on cards in the main conversation. */
|
|
onboardingOpen: boolean;
|
|
}
|
|
|
|
function isUniqueViolation(error: unknown): boolean {
|
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002");
|
|
}
|
|
|
|
const mainBotSelect = {
|
|
id: true,
|
|
spaceId: true,
|
|
name: true,
|
|
archivedAt: true,
|
|
thread: { select: { id: true } },
|
|
} as const;
|
|
|
|
async function findMainBot(prisma: PrismaClient, userId: string, spaceId: string) {
|
|
return prisma.bot.findFirst({ where: { userId, spaceId, kind: "main" }, select: mainBotSelect });
|
|
}
|
|
|
|
/**
|
|
* The user's active main bot in `spaceId`, or in their home space when it is omitted. Every
|
|
* space the user works in has its own main bot: the home one is their personal main agent, a
|
|
* project's leads that project.
|
|
*/
|
|
export async function findMainBotId(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
spaceId?: string,
|
|
): Promise<string | null> {
|
|
const space = spaceId ?? (await requireMembership(prisma, userId)).spaceId;
|
|
const bot = await prisma.bot.findFirst({
|
|
where: { userId, spaceId: space, kind: "main", archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
return bot?.id ?? null;
|
|
}
|
|
|
|
/**
|
|
* Whether a bot is the user's personal main agent, the main bot of their home space. Goals,
|
|
* habits, check-ins and nudges belong to it alone; a project's main bot leads only that project.
|
|
*/
|
|
export async function isPersonalMainBot(
|
|
prisma: Pick<PrismaClient, "space">,
|
|
bot: { kind: string; spaceId: string },
|
|
): Promise<boolean> {
|
|
if (bot.kind !== "main") return false;
|
|
const space = await prisma.space.findUnique({
|
|
where: { id: bot.spaceId },
|
|
select: { isDefault: true },
|
|
});
|
|
return space?.isDefault === true;
|
|
}
|
|
|
|
/**
|
|
* Returns the user's main bot in `spaceId` (default: the home space), creating it when it does
|
|
* not exist yet. In the home space, a first bot that onboarding already created is promoted
|
|
* instead of duplicated. An archived main bot is restored, because a main bot cannot be retired.
|
|
*/
|
|
export async function ensureMainBot(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
spaceId?: string,
|
|
): Promise<MainBotRef> {
|
|
const actor = await requireMembership(prisma, userId, spaceId);
|
|
let main = await findMainBot(prisma, userId, actor.spaceId);
|
|
if (!main) {
|
|
const space = await prisma.space.findUnique({
|
|
where: { id: actor.spaceId },
|
|
select: { isDefault: true },
|
|
});
|
|
const home = space?.isDefault === true || spaceId === undefined;
|
|
const first = home
|
|
? await prisma.bot.findFirst({
|
|
where: {
|
|
userId,
|
|
spaceId: actor.spaceId,
|
|
spawnKey: MAIN_BOT_SPAWN_KEY,
|
|
archivedAt: null,
|
|
},
|
|
select: { id: true },
|
|
})
|
|
: null;
|
|
try {
|
|
if (first) {
|
|
await prisma.bot.update({ where: { id: first.id }, data: { kind: "main" } });
|
|
} else {
|
|
// spawnKey is unique per space, so in a space shared with another user who already
|
|
// holds it, the main bot is created without one. Projects never carry it.
|
|
const keyHolder = home
|
|
? await prisma.bot.findUnique({
|
|
where: { spaceId_spawnKey: { spaceId: actor.spaceId, spawnKey: MAIN_BOT_SPAWN_KEY } },
|
|
select: { userId: true },
|
|
})
|
|
: null;
|
|
await createRepos(prisma).createBot(actor, {
|
|
...(home ? MAIN_BOT_DEFAULTS : PROJECT_MAIN_BOT_DEFAULTS),
|
|
instructions: "",
|
|
notifyOnFinish: true,
|
|
...(home && (!keyHolder || keyHolder.userId === userId)
|
|
? { spawnKey: MAIN_BOT_SPAWN_KEY }
|
|
: {}),
|
|
kind: "main",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// A concurrent call promoted or created the main bot first.
|
|
if (!isUniqueViolation(error)) throw error;
|
|
}
|
|
main = await findMainBot(prisma, userId, actor.spaceId);
|
|
if (!main) throw new IsolationError("Main bot could not be created");
|
|
}
|
|
if (main.archivedAt) {
|
|
await prisma.bot.update({ where: { id: main.id }, data: { archivedAt: null } });
|
|
}
|
|
if (!main.thread) throw new IsolationError("Main bot is missing its thread");
|
|
return { id: main.id, spaceId: main.spaceId, threadId: main.thread.id, name: main.name };
|
|
}
|
|
|
|
async function findMainConversation(
|
|
prisma: PrismaClient,
|
|
main: { userId: string; spaceId: string; botId: string },
|
|
) {
|
|
return prisma.chatGroup.findFirst({
|
|
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
|
|
select: {
|
|
id: true,
|
|
spaceId: true,
|
|
leadBotId: true,
|
|
archivedAt: true,
|
|
thread: { select: { id: true } },
|
|
members: { where: { botId: main.botId }, select: { id: true } },
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the user's main conversation in `spaceId` (default: the home space): a group thread
|
|
* whose lead and default responder is that space's main bot. It is created on first use and
|
|
* repaired when its lead was lost or it was archived.
|
|
*/
|
|
export async function ensureMainConversation(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
spaceId?: string,
|
|
): Promise<MainConversation> {
|
|
const main = await ensureMainBot(prisma, userId, spaceId);
|
|
const key = { userId, spaceId: main.spaceId, botId: main.id };
|
|
let group = await findMainConversation(prisma, key);
|
|
if (!group) {
|
|
try {
|
|
await prisma.$transaction(async (tx) => {
|
|
await lockSpaceForContentCreation(tx, { spaceId: main.spaceId, userId });
|
|
const created = await tx.chatGroup.create({
|
|
data: {
|
|
spaceId: main.spaceId,
|
|
userId,
|
|
name: main.name,
|
|
kind: "main",
|
|
leadBotId: main.id,
|
|
},
|
|
});
|
|
await tx.chatGroupMember.create({ data: { groupId: created.id, botId: main.id } });
|
|
await tx.thread.create({
|
|
data: { spaceId: main.spaceId, groupId: created.id, userId },
|
|
});
|
|
});
|
|
} catch (error) {
|
|
// A concurrent call created the main conversation first.
|
|
if (!isUniqueViolation(error)) throw error;
|
|
}
|
|
group = await findMainConversation(prisma, key);
|
|
} else {
|
|
const repairs: { leadBotId?: string; archivedAt?: null } = {};
|
|
if (group.leadBotId !== main.id) repairs.leadBotId = main.id;
|
|
if (group.archivedAt) repairs.archivedAt = null;
|
|
if (repairs.leadBotId !== undefined || repairs.archivedAt !== undefined) {
|
|
await prisma.chatGroup.update({ where: { id: group.id }, data: repairs });
|
|
}
|
|
if (group.members.length === 0) {
|
|
await prisma.chatGroupMember.createMany({
|
|
data: [{ groupId: group.id, botId: main.id }],
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
}
|
|
if (!group?.thread) throw new IsolationError("Main conversation could not be created");
|
|
return {
|
|
groupId: group.id,
|
|
threadId: group.thread.id,
|
|
spaceId: group.spaceId,
|
|
leadBotId: main.id,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Adds bots to the user's main conversation in `spaceId` (default: the home space) so they
|
|
* appear on its stage. Only active bots of the same user in that space qualify; others are
|
|
* skipped. Returns the ids that were not members before, so callers can announce the change.
|
|
*/
|
|
export async function addMainConversationMembers(
|
|
prisma: PrismaClient,
|
|
input: { userId: string; spaceId?: string; botIds: string[] },
|
|
): Promise<string[]> {
|
|
const requested = [...new Set(input.botIds)];
|
|
if (requested.length === 0) return [];
|
|
const conversation = await ensureMainConversation(prisma, input.userId, input.spaceId);
|
|
const bots = await prisma.bot.findMany({
|
|
where: {
|
|
id: { in: requested },
|
|
userId: input.userId,
|
|
spaceId: conversation.spaceId,
|
|
archivedAt: null,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (bots.length === 0) return [];
|
|
const existing = await prisma.chatGroupMember.findMany({
|
|
where: { groupId: conversation.groupId, botId: { in: bots.map((bot) => bot.id) } },
|
|
select: { botId: true },
|
|
});
|
|
const present = new Set(existing.map((member) => member.botId));
|
|
const added = bots.map((bot) => bot.id).filter((botId) => !present.has(botId));
|
|
if (added.length > 0) {
|
|
await prisma.chatGroupMember.createMany({
|
|
data: added.map((botId) => ({ groupId: conversation.groupId, botId })),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
return added;
|
|
}
|
|
|
|
/**
|
|
* Adds bots to the main conversation and, when any joined, appends a `group.updated` event to
|
|
* its thread so open clients refresh the stage. Returns the ids that joined.
|
|
*/
|
|
export async function joinMainConversation(
|
|
prisma: PrismaClient,
|
|
input: { userId: string; spaceId?: string; botIds: string[] },
|
|
realtime?: RealtimeFanout,
|
|
): Promise<string[]> {
|
|
const added = await addMainConversationMembers(prisma, input);
|
|
if (added.length === 0) return added;
|
|
const conversation = await ensureMainConversation(prisma, input.userId, input.spaceId);
|
|
await appendEvent(
|
|
prisma,
|
|
{
|
|
spaceId: conversation.spaceId,
|
|
threadId: conversation.threadId,
|
|
botId: conversation.leadBotId,
|
|
type: "group.updated",
|
|
payload: { groupId: conversation.groupId, addedBotIds: added },
|
|
},
|
|
realtime,
|
|
);
|
|
return added;
|
|
}
|
|
|
|
/** Profile and active facts a run in `spaceId` may use; pending and other-space facts stay out. */
|
|
export async function loadUserContext(
|
|
prisma: PrismaClient,
|
|
input: { userId: string; spaceId: string },
|
|
): Promise<UserContext> {
|
|
const [user, profile, facts] = await Promise.all([
|
|
prisma.user.findUnique({
|
|
where: { id: input.userId },
|
|
select: { name: true, onboarding: { select: { completedAt: true } } },
|
|
}),
|
|
prisma.userProfile.findUnique({ where: { userId: input.userId } }),
|
|
prisma.userFact.findMany({
|
|
where: {
|
|
userId: input.userId,
|
|
status: "active",
|
|
OR: [{ allSpaces: true }, { visibleSpaces: { some: { spaceId: input.spaceId } } }],
|
|
},
|
|
orderBy: [{ category: "asc" }, { updatedAt: "desc" }],
|
|
take: USER_CONTEXT_FACT_LIMIT,
|
|
select: { category: true, value: true, confidence: true },
|
|
}),
|
|
]);
|
|
return {
|
|
name: user?.name ?? "",
|
|
timeZone: profile?.timeZone ?? null,
|
|
locale: profile?.locale ?? null,
|
|
language: profile?.language ?? null,
|
|
communicationStyle: profile?.communicationStyle ?? "",
|
|
summary: profile?.summary ?? "",
|
|
facts: facts.map((fact) => ({
|
|
category: fact.category as UserFactCategory,
|
|
value: fact.value,
|
|
confidence: fact.confidence,
|
|
})),
|
|
onboardingOpen: Boolean(user?.onboarding && !user.onboarding.completedAt),
|
|
};
|
|
}
|