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.
558 lines
17 KiB
TypeScript
558 lines
17 KiB
TypeScript
import {
|
|
type Actor,
|
|
GROUP_MEMBER_MAX,
|
|
GROUP_MEMBER_MIN,
|
|
type Group,
|
|
type GroupMember,
|
|
type ProjectSession,
|
|
type RunStatus,
|
|
type SpaceGroup,
|
|
} from "@rakazo/contracts";
|
|
import { ACTIVE_RUN_STATUSES } from "@rakazo/core";
|
|
import { cancelRunsInTransaction } from "./cancel-runs.js";
|
|
import type { Prisma, PrismaClient } from "./client.js";
|
|
import { expireComputerExecutionLeases } from "./computers.js";
|
|
import { IsolationError } from "./scope.js";
|
|
import { lockSpaceForContentCreation } from "./spaces.js";
|
|
import { activeRunSelection, activeRunStatuses, previewFromBlocks } from "./thread-listing.js";
|
|
|
|
type GroupRecord = {
|
|
id: string;
|
|
spaceId: string;
|
|
userId: string;
|
|
name: string;
|
|
pinned: boolean;
|
|
sectionId: string | null;
|
|
archivedAt: Date | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
thread: {
|
|
id: string;
|
|
unread: boolean;
|
|
messages: Array<{ blocks: unknown }>;
|
|
} | null;
|
|
members: Array<{
|
|
bot: {
|
|
id: string;
|
|
name: string;
|
|
color: string;
|
|
runs: Array<{ status: string }>;
|
|
};
|
|
}>;
|
|
};
|
|
|
|
type SpaceGroupRecord = Pick<
|
|
GroupRecord,
|
|
"id" | "spaceId" | "name" | "pinned" | "sectionId" | "updatedAt" | "members"
|
|
> & {
|
|
thread: {
|
|
unread: boolean;
|
|
messages: Array<{ blocks: unknown }>;
|
|
} | null;
|
|
};
|
|
|
|
function mapGroupMembers(members: GroupRecord["members"]): GroupMember[] {
|
|
return members.map((member) => ({
|
|
botId: member.bot.id,
|
|
name: member.bot.name,
|
|
color: member.bot.color,
|
|
status: member.bot.runs[0]?.status ?? "idle",
|
|
}));
|
|
}
|
|
|
|
function mapGroup(group: GroupRecord): Group {
|
|
if (!group.thread) throw new IsolationError("Group is missing its thread");
|
|
const preview = previewFromBlocks(group.thread.messages[0]?.blocks);
|
|
return {
|
|
id: group.id,
|
|
spaceId: group.spaceId,
|
|
name: group.name,
|
|
pinned: group.pinned,
|
|
sectionId: group.sectionId,
|
|
archivedAt: group.archivedAt?.toISOString() ?? null,
|
|
members: mapGroupMembers(group.members),
|
|
threadId: group.thread.id,
|
|
preview,
|
|
unread: group.thread.unread,
|
|
updatedAt: group.updatedAt.toISOString(),
|
|
createdAt: group.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
function promptSnippet(text: string, max = 120): string {
|
|
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
if (oneLine.length <= max) return oneLine;
|
|
return `${oneLine.slice(0, max - 1)}…`;
|
|
}
|
|
|
|
function sessionStatusFromRuns(runs: Array<{ status: string }>): RunStatus {
|
|
if (runs.some((run) => (ACTIVE_RUN_STATUSES as readonly string[]).includes(run.status))) {
|
|
if (runs.some((run) => run.status === "waiting_input")) return "waiting_input";
|
|
if (runs.some((run) => run.status === "waiting_takeover")) return "waiting_takeover";
|
|
if (runs.some((run) => run.status === "failed")) return "failed";
|
|
return "running";
|
|
}
|
|
if (runs.some((run) => run.status === "failed")) return "failed";
|
|
if (runs.some((run) => run.status === "cancelled")) return "cancelled";
|
|
return "completed";
|
|
}
|
|
|
|
function mapSpaceGroup(group: SpaceGroupRecord): SpaceGroup {
|
|
if (!group.thread) throw new IsolationError("Group is missing its thread");
|
|
return {
|
|
id: group.id,
|
|
spaceId: group.spaceId,
|
|
name: group.name,
|
|
pinned: group.pinned,
|
|
sectionId: group.sectionId,
|
|
members: mapGroupMembers(group.members),
|
|
preview: previewFromBlocks(group.thread.messages[0]?.blocks),
|
|
unread: group.thread.unread,
|
|
updatedAt: group.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
function hasMinimumActiveMembers(members: readonly unknown[]) {
|
|
return members.length >= GROUP_MEMBER_MIN;
|
|
}
|
|
|
|
async function assertOwnedBots(
|
|
prisma: PrismaClient,
|
|
actor: Actor,
|
|
botIds: string[],
|
|
): Promise<GroupMember[]> {
|
|
const unique = [...new Set(botIds)];
|
|
if (unique.length < GROUP_MEMBER_MIN || unique.length > GROUP_MEMBER_MAX) {
|
|
throw new IsolationError(
|
|
`Groups require ${GROUP_MEMBER_MIN} to ${GROUP_MEMBER_MAX} distinct bots`,
|
|
);
|
|
}
|
|
const bots = await prisma.bot.findMany({
|
|
where: {
|
|
id: { in: unique },
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
archivedAt: null,
|
|
},
|
|
select: { id: true, name: true, color: true },
|
|
});
|
|
if (bots.length !== unique.length) throw new IsolationError();
|
|
const botsById = new Map(bots.map((bot) => [bot.id, bot]));
|
|
return unique.map((botId) => {
|
|
const bot = botsById.get(botId);
|
|
if (!bot) throw new IsolationError();
|
|
return { botId: bot.id, name: bot.name, color: bot.color };
|
|
});
|
|
}
|
|
|
|
const groupInclude = {
|
|
thread: {
|
|
include: {
|
|
messages: { orderBy: { seq: "desc" as const }, take: 1 },
|
|
},
|
|
},
|
|
members: {
|
|
where: { bot: { archivedAt: null } },
|
|
include: {
|
|
bot: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
color: true,
|
|
runs: activeRunSelection,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: "asc" as const },
|
|
},
|
|
} as const;
|
|
|
|
const groupTargetInclude = {
|
|
thread: { select: { id: true } },
|
|
members: {
|
|
where: { bot: { archivedAt: null } },
|
|
include: {
|
|
bot: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
color: true,
|
|
runs: activeRunSelection,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: "asc" as const },
|
|
},
|
|
} as const;
|
|
|
|
export function createGroupRepos(prisma: PrismaClient) {
|
|
async function listSpaceGroupsForSpaces(actor: Actor, spaceIds: string[]): Promise<SpaceGroup[]> {
|
|
if (spaceIds.length === 0) return [];
|
|
const groups = await prisma.chatGroup.findMany({
|
|
where: {
|
|
spaceId: { in: spaceIds },
|
|
userId: actor.userId,
|
|
archivedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
spaceId: true,
|
|
name: true,
|
|
pinned: true,
|
|
sectionId: true,
|
|
updatedAt: true,
|
|
thread: {
|
|
select: {
|
|
unread: true,
|
|
messages: {
|
|
orderBy: { seq: "desc" },
|
|
take: 1,
|
|
select: { blocks: true },
|
|
},
|
|
},
|
|
},
|
|
members: groupInclude.members,
|
|
},
|
|
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
|
|
});
|
|
return groups
|
|
.filter((group) => hasMinimumActiveMembers(group.members))
|
|
.map((group) => mapSpaceGroup(group));
|
|
}
|
|
|
|
return {
|
|
async listGroups(actor: Actor, options: { archived?: boolean } = {}): Promise<Group[]> {
|
|
const groups = await prisma.chatGroup.findMany({
|
|
where: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
archivedAt: options.archived ? { not: null } : null,
|
|
},
|
|
include: groupInclude,
|
|
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
|
|
});
|
|
return groups
|
|
.filter((group) => hasMinimumActiveMembers(group.members))
|
|
.map((group) => mapGroup(group as GroupRecord));
|
|
},
|
|
|
|
listSpaceGroupsForSpaces,
|
|
|
|
async getGroup(actor: Actor, groupId: string, options: { includeArchived?: boolean } = {}) {
|
|
const group = await prisma.chatGroup.findFirst({
|
|
where: {
|
|
id: groupId,
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
...(options.includeArchived ? {} : { archivedAt: null }),
|
|
},
|
|
include: groupInclude,
|
|
});
|
|
if (!group || !hasMinimumActiveMembers(group.members)) throw new IsolationError();
|
|
return group as GroupRecord;
|
|
},
|
|
|
|
async getGroupTarget(actor: Actor, groupId: string) {
|
|
const group = await prisma.chatGroup.findFirst({
|
|
where: {
|
|
id: groupId,
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
archivedAt: null,
|
|
},
|
|
include: groupTargetInclude,
|
|
});
|
|
if (!group || !hasMinimumActiveMembers(group.members)) throw new IsolationError();
|
|
return group;
|
|
},
|
|
|
|
async createGroup(actor: Actor, input: { name: string; botIds: string[] }): Promise<Group> {
|
|
const members = await assertOwnedBots(prisma, actor, input.botIds);
|
|
const created = await prisma.$transaction(async (tx) => {
|
|
await lockSpaceForContentCreation(tx, {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
});
|
|
const group = await tx.chatGroup.create({
|
|
data: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
name: input.name.trim(),
|
|
},
|
|
});
|
|
await tx.chatGroupMember.createMany({
|
|
data: members.map((member) => ({ groupId: group.id, botId: member.botId })),
|
|
});
|
|
await tx.thread.create({
|
|
data: {
|
|
spaceId: actor.spaceId,
|
|
groupId: group.id,
|
|
userId: actor.userId,
|
|
},
|
|
});
|
|
return tx.chatGroup.findFirstOrThrow({
|
|
where: { id: group.id },
|
|
include: groupInclude,
|
|
});
|
|
});
|
|
return mapGroup(created as GroupRecord);
|
|
},
|
|
|
|
async updateGroup(
|
|
actor: Actor,
|
|
input: {
|
|
groupId: string;
|
|
name?: string;
|
|
botIds?: string[];
|
|
pinned?: boolean;
|
|
sectionId?: string | null;
|
|
},
|
|
): Promise<{ group: Group; cancelledRunIds: string[] }> {
|
|
const members = input.botIds ? await assertOwnedBots(prisma, actor, input.botIds) : undefined;
|
|
const updated = await prisma.$transaction(async (tx) => {
|
|
await lockOwnedGroup(tx, actor, input.groupId);
|
|
const current = await tx.chatGroup.findFirst({
|
|
where: {
|
|
id: input.groupId,
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
archivedAt: null,
|
|
},
|
|
include: {
|
|
members: { select: { botId: true, bot: { select: { archivedAt: true } } } },
|
|
thread: { select: { id: true } },
|
|
},
|
|
});
|
|
if (!current?.thread) throw new IsolationError();
|
|
if (
|
|
!members &&
|
|
!hasMinimumActiveMembers(
|
|
current.members.filter((member) => member.bot.archivedAt === null),
|
|
)
|
|
) {
|
|
throw new IsolationError();
|
|
}
|
|
const nextBotIds = new Set(
|
|
members?.map((member) => member.botId) ?? current.members.map((member) => member.botId),
|
|
);
|
|
const removedBotIds = current.members
|
|
.map((member) => member.botId)
|
|
.filter((botId) => !nextBotIds.has(botId));
|
|
const activeRuns = removedBotIds.length
|
|
? await tx.run.findMany({
|
|
where: {
|
|
threadId: current.thread.id,
|
|
botId: { in: removedBotIds },
|
|
status: {
|
|
in: ["queued", "leased", "running", "waiting_input", "waiting_takeover"],
|
|
},
|
|
},
|
|
select: { id: true, taskId: true },
|
|
})
|
|
: [];
|
|
if (activeRuns.length) {
|
|
const now = new Date();
|
|
await cancelRunsInTransaction(tx, activeRuns, now);
|
|
}
|
|
if (input.name !== undefined) {
|
|
await tx.chatGroup.update({
|
|
where: { id: input.groupId },
|
|
data: { name: input.name.trim() },
|
|
});
|
|
}
|
|
if (members) {
|
|
await tx.chatGroupMember.deleteMany({ where: { groupId: input.groupId } });
|
|
await tx.chatGroupMember.createMany({
|
|
data: members.map((member) => ({ groupId: input.groupId, botId: member.botId })),
|
|
});
|
|
}
|
|
await tx.chatGroup.update({
|
|
where: { id: input.groupId },
|
|
data: {
|
|
updatedAt: new Date(),
|
|
pinned: input.pinned,
|
|
sectionId: input.sectionId,
|
|
},
|
|
});
|
|
return tx.chatGroup
|
|
.findFirstOrThrow({
|
|
where: { id: input.groupId },
|
|
include: groupInclude,
|
|
})
|
|
.then((group) => ({ group, cancelledRunIds: activeRuns.map((run) => run.id) }));
|
|
});
|
|
if (!updated.group.thread) throw new IsolationError();
|
|
return {
|
|
group: mapGroup(updated.group as GroupRecord),
|
|
cancelledRunIds: updated.cancelledRunIds,
|
|
};
|
|
},
|
|
|
|
async archiveGroup(actor: Actor, groupId: string) {
|
|
return prisma.$transaction(async (tx) => {
|
|
await lockOwnedGroup(tx, actor, groupId);
|
|
const current = await tx.chatGroup.findFirst({
|
|
where: {
|
|
id: groupId,
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
archivedAt: null,
|
|
},
|
|
select: { thread: { select: { id: true } } },
|
|
});
|
|
if (!current?.thread) throw new IsolationError();
|
|
|
|
const activeRuns = await tx.run.findMany({
|
|
where: {
|
|
threadId: current.thread.id,
|
|
status: { in: activeRunStatuses },
|
|
},
|
|
select: { id: true, taskId: true },
|
|
});
|
|
const runIds = activeRuns.map((run) => run.id);
|
|
const now = new Date();
|
|
const computers = runIds.length
|
|
? await tx.computer.findMany({
|
|
where: { executionRunId: { in: runIds } },
|
|
select: {
|
|
id: true,
|
|
homeKey: true,
|
|
kind: true,
|
|
providerRef: true,
|
|
executionBotId: true,
|
|
executionRunId: true,
|
|
},
|
|
})
|
|
: [];
|
|
const leases = runIds.length
|
|
? await tx.computerExecutionLease.findMany({
|
|
where: { runId: { in: runIds } },
|
|
select: { computerId: true, runId: true, fence: true },
|
|
})
|
|
: [];
|
|
const leaseByComputerId = new Map(leases.map((lease) => [lease.computerId, lease]));
|
|
const computersWithLease = computers.map((computer) => ({
|
|
...computer,
|
|
executionFence: leaseByComputerId.get(computer.id)?.fence ?? 0,
|
|
}));
|
|
|
|
if (runIds.length) {
|
|
await cancelRunsInTransaction(tx, activeRuns, now);
|
|
await expireComputerExecutionLeases(tx, { runId: { in: runIds } });
|
|
await tx.computer.updateMany({
|
|
where: { executionRunId: { in: runIds } },
|
|
data: {
|
|
executionRunId: null,
|
|
executionBotId: null,
|
|
executionLeaseExpiresAt: null,
|
|
},
|
|
});
|
|
await tx.event.deleteMany({
|
|
where: { type: "thread.progress", runId: { in: runIds } },
|
|
});
|
|
}
|
|
|
|
await tx.chatGroup.update({
|
|
where: { id: groupId },
|
|
data: { archivedAt: now, pinned: false },
|
|
});
|
|
|
|
return { cancelledRunIds: runIds, computers: computersWithLease };
|
|
});
|
|
},
|
|
|
|
async restoreGroup(actor: Actor, groupId: string) {
|
|
const restored = await prisma.chatGroup.updateMany({
|
|
where: { id: groupId, spaceId: actor.spaceId, userId: actor.userId },
|
|
data: { archivedAt: null },
|
|
});
|
|
if (restored.count !== 1) throw new IsolationError();
|
|
},
|
|
|
|
async removeGroup(actor: Actor, groupId: string) {
|
|
return prisma.$transaction(async (tx) => {
|
|
await lockOwnedGroup(tx, actor, groupId);
|
|
const group = await tx.chatGroup.findUnique({
|
|
where: { id: groupId },
|
|
select: {
|
|
artifacts: { select: { storageKey: true } },
|
|
members: { orderBy: { createdAt: "asc" }, take: 1, select: { botId: true } },
|
|
},
|
|
});
|
|
const contextBotId = group?.members[0]?.botId;
|
|
if (!contextBotId) throw new IsolationError();
|
|
await tx.chatGroup.delete({ where: { id: groupId } });
|
|
return {
|
|
contextBotId,
|
|
artifactStorageKeys: group.artifacts.map((artifact) => artifact.storageKey),
|
|
};
|
|
});
|
|
},
|
|
|
|
async listSessions(actor: Actor, groupId: string): Promise<ProjectSession[]> {
|
|
const group = await prisma.chatGroup.findFirst({
|
|
where: {
|
|
id: groupId,
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
archivedAt: null,
|
|
},
|
|
select: { thread: { select: { id: true } } },
|
|
});
|
|
if (!group?.thread) throw new IsolationError();
|
|
const messages = await prisma.message.findMany({
|
|
where: { threadId: group.thread.id, role: "user" },
|
|
orderBy: { seq: "desc" },
|
|
take: 50,
|
|
select: {
|
|
id: true,
|
|
blocks: true,
|
|
createdAt: true,
|
|
sourceRuns: {
|
|
select: {
|
|
status: true,
|
|
bot: { select: { name: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return messages.map((message) => ({
|
|
sessionId: message.id,
|
|
sourceMessageId: message.id,
|
|
promptSnippet: promptSnippet(previewFromBlocks(message.blocks) || "Neue Unterhaltung"),
|
|
startedAt: message.createdAt.toISOString(),
|
|
status: sessionStatusFromRuns(message.sourceRuns),
|
|
botNames: [...new Set(message.sourceRuns.map((run) => run.bot.name))],
|
|
}));
|
|
},
|
|
|
|
mapGroup,
|
|
};
|
|
}
|
|
|
|
export async function lockOwnedGroup(
|
|
prisma: Pick<Prisma.TransactionClient, "$queryRaw">,
|
|
actor: Pick<Actor, "spaceId" | "userId">,
|
|
groupId: string,
|
|
) {
|
|
const locked = await prisma.$queryRaw<Array<{ id: string }>>`
|
|
SELECT id
|
|
FROM chat_groups
|
|
WHERE id = ${groupId}
|
|
AND "spaceId" = ${actor.spaceId}
|
|
AND "userId" = ${actor.userId}
|
|
FOR UPDATE
|
|
`;
|
|
if (locked.length !== 1) throw new IsolationError();
|
|
}
|
|
|
|
export async function touchGroupUpdatedAt(
|
|
prisma: Pick<Prisma.TransactionClient, "chatGroup">,
|
|
groupId: string,
|
|
) {
|
|
await prisma.chatGroup.update({
|
|
where: { id: groupId },
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
}
|