* Resume a waiting run after takeover even when its execution lease is gone When resuming a run failed while it waited for the user, the run stayed waiting_takeover but lost its execution lease. Taking over then bound no run, so handing control back resumed nothing and the chat hung at the computer. Takeover now binds the newest leaseless waiting run. The stage also counted waiting runs as work and showed "working" while the bot waited on the user; it now listens instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Tell every bot it is an agent in evOo, not the model behind it Local models introduced themselves as the assistant product they were trained as and even asked for that product's login. The identity block now names evOo, and the fallback prompts say evOo instead of Rakazo. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Show runs waiting on the user in Today from every space Today and its badge listed only runs of the selected space, while the sidebar shows chats from all spaces. A chat waiting on the user in the home space therefore showed "needs you" in the sidebar and "All quiet" in Today. The activity list now spans every space the user belongs to, carries each run's space, and opening a run switches to that space on web and mobile. The runs-list Postgres suite was not part of the integration run; it is now, with a case for a waiting run in another space. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Keep a project's main conversation to its own asks Every main conversation showed the inbox of all the user's spaces, so a takeover waiting in the home chat sat above the composer of every project. Only the home space's main conversation collects other spaces now; a project shows its own items. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Also resume a waiting run that sits behind an older run's inactive lease Inactive execution leases stay as fencing tombstones, so a run that lost its lease can wait behind an expired lease of an earlier run. Takeover now looks for the bot's waiting run whenever the lease is neither busy nor bound to a waiting run. Journey 4f covers both the released and the stale lease. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Translate the voice awareness labels and the computer card's state The voice strip showed hardcoded English labels such as "In progress", and the computer card showed its stored "Needs you" state untranslated in German chats. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ --------- Co-authored-by: Claude <noreply@anthropic.com>
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
import type { Actor, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
|
|
import { MessageBlock, SUGGESTION_TTL_MS } from "@rakazo/contracts";
|
|
import { isApprovalAskBlock } from "@rakazo/core";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { promptSnippet } from "./runs.js";
|
|
|
|
/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */
|
|
const INBOX_RUN_LIMIT = 50;
|
|
const INBOX_UNREAD_LIMIT = 50;
|
|
const INBOX_PREVIEW_LENGTH = 280;
|
|
|
|
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
|
|
type CardBlock = Extract<MessageBlock, { kind: "card" }>;
|
|
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
|
|
type InboxBot = { id: string; name: string; color: string };
|
|
type InboxSpace = NonNullable<InboxItem["space"]>;
|
|
|
|
export interface InboxRunRow {
|
|
id: string;
|
|
spaceId: string;
|
|
status: string;
|
|
updatedAt: Date;
|
|
bot: InboxBot;
|
|
thread: {
|
|
botId: string | null;
|
|
groupId: string | null;
|
|
group: { kind: string; archivedAt: Date | null } | null;
|
|
};
|
|
}
|
|
|
|
export interface InboxMessageRow {
|
|
id: string;
|
|
runId: string | null;
|
|
blocks: unknown;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export interface InboxUnreadThreadRow {
|
|
spaceId: string;
|
|
createdAt: Date;
|
|
bot: InboxBot | null;
|
|
/** The newest bot message, if any. */
|
|
messages: Array<Omit<InboxMessageRow, "runId">>;
|
|
}
|
|
|
|
/** An open suggestion: the card holding it and the thread it was posted in. */
|
|
export interface InboxSuggestionRow {
|
|
spaceId: string;
|
|
createdAt: Date;
|
|
bot: InboxBot;
|
|
thread: InboxRunRow["thread"];
|
|
messageId: string;
|
|
cardId: string;
|
|
/** The card message's blocks. */
|
|
blocks: unknown;
|
|
}
|
|
|
|
function parseBlocks(blocks: unknown): MessageBlock[] {
|
|
const parsed = MessageBlock.array().safeParse(blocks);
|
|
return parsed.success ? parsed.data : [];
|
|
}
|
|
|
|
function pendingAsk(blocks: MessageBlock[]): AskBlock | undefined {
|
|
return blocks.find(
|
|
(block): block is AskBlock => block.kind === "ask" && block.status !== "answered",
|
|
);
|
|
}
|
|
|
|
function takeoverRequest(blocks: MessageBlock[]): ComputerBlock | undefined {
|
|
return blocks.find((block): block is ComputerBlock => block.kind === "computer");
|
|
}
|
|
|
|
/** The newest message whose blocks hold what `pick` looks for. */
|
|
function newest<T>(messages: InboxMessageRow[], pick: (blocks: MessageBlock[]) => T | undefined) {
|
|
for (const message of messages) {
|
|
const block = pick(parseBlocks(message.blocks));
|
|
if (block) return { message, block };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function previewText(blocks: unknown): string {
|
|
for (const block of parseBlocks(blocks)) {
|
|
if ("text" in block && typeof block.text === "string" && block.text.trim()) {
|
|
return promptSnippet(block.text, INBOX_PREVIEW_LENGTH);
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Where an ask can be answered from. The open space's main conversation already shows its own
|
|
* asks (a project's main conversation does not), and an archived group or an external
|
|
* conversation has no thread target to answer through.
|
|
*/
|
|
function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null {
|
|
if (thread.groupId) {
|
|
if (!thread.group || thread.group.archivedAt) return null;
|
|
if (thread.group.kind === "main" && currentSpace) return null;
|
|
return { groupId: thread.groupId };
|
|
}
|
|
return thread.botId ? { botId: thread.botId } : null;
|
|
}
|
|
|
|
function targetKey(target: InboxThreadTarget): string {
|
|
return "botId" in target ? `bot:${target.botId}` : `group:${target.groupId}`;
|
|
}
|
|
|
|
function byCreatedAt(left: InboxItem, right: InboxItem): number {
|
|
return left.createdAt.localeCompare(right.createdAt);
|
|
}
|
|
|
|
/**
|
|
* Turns waiting runs, their ask and takeover messages, open suggestions and unread threads into
|
|
* inbox items:
|
|
* everything that waits on the user first, then unread threads not already covered, each
|
|
* oldest first. Items from another space name it.
|
|
*/
|
|
export function buildInboxItems(input: {
|
|
runs: InboxRunRow[];
|
|
/** Ask and takeover messages of those runs, newest first. */
|
|
messages: InboxMessageRow[];
|
|
unreadThreads: InboxUnreadThreadRow[];
|
|
/** Open suggestions, wherever their card was posted. */
|
|
suggestions?: InboxSuggestionRow[];
|
|
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
|
|
otherSpaces?: ReadonlyMap<string, InboxSpace>;
|
|
}): InboxItem[] {
|
|
const spaceOf = (spaceId: string) => {
|
|
const space = input.otherSpaces?.get(spaceId);
|
|
return space ? { space } : {};
|
|
};
|
|
const messagesByRun = new Map<string, InboxMessageRow[]>();
|
|
for (const message of input.messages) {
|
|
if (!message.runId) continue;
|
|
const list = messagesByRun.get(message.runId) ?? [];
|
|
list.push(message);
|
|
messagesByRun.set(message.runId, list);
|
|
}
|
|
|
|
const waiting: InboxItem[] = [];
|
|
for (const run of input.runs) {
|
|
const messages = messagesByRun.get(run.id) ?? [];
|
|
if (run.status === "waiting_input") {
|
|
const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId));
|
|
const pending = target ? newest(messages, pendingAsk) : undefined;
|
|
if (!target || !pending) continue;
|
|
waiting.push({
|
|
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
|
|
bot: run.bot,
|
|
target,
|
|
...spaceOf(run.spaceId),
|
|
runId: run.id,
|
|
messageId: pending.message.id,
|
|
ask: pending.block,
|
|
createdAt: pending.message.createdAt.toISOString(),
|
|
});
|
|
} else if (run.status === "waiting_takeover") {
|
|
// Takeover controls live in the bot's own thread, so the item stays even for a run that
|
|
// asked in the main conversation.
|
|
const request = newest(messages, takeoverRequest);
|
|
waiting.push({
|
|
kind: "takeover",
|
|
bot: run.bot,
|
|
target: { botId: run.bot.id },
|
|
...spaceOf(run.spaceId),
|
|
runId: run.id,
|
|
messageId: request?.message.id ?? null,
|
|
text: request?.block.text ?? "",
|
|
createdAt: (request?.message.createdAt ?? run.updatedAt).toISOString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const suggestion of input.suggestions ?? []) {
|
|
const target = askTarget(suggestion.thread, !input.otherSpaces?.has(suggestion.spaceId));
|
|
const card = parseBlocks(suggestion.blocks).find(
|
|
(block): block is CardBlock => block.kind === "card" && block.id === suggestion.cardId,
|
|
);
|
|
if (!target || card?.suggestion?.status !== "proposed") continue;
|
|
waiting.push({
|
|
kind: "suggestion",
|
|
bot: suggestion.bot,
|
|
target,
|
|
...spaceOf(suggestion.spaceId),
|
|
messageId: suggestion.messageId,
|
|
card,
|
|
createdAt: suggestion.createdAt.toISOString(),
|
|
});
|
|
}
|
|
|
|
const covered = new Set(waiting.map((item) => targetKey(item.target)));
|
|
const unread: InboxItem[] = [];
|
|
for (const thread of input.unreadThreads) {
|
|
if (!thread.bot) continue;
|
|
const target = { botId: thread.bot.id };
|
|
if (covered.has(targetKey(target))) continue;
|
|
const latest = thread.messages[0];
|
|
unread.push({
|
|
kind: "unread",
|
|
bot: thread.bot,
|
|
target,
|
|
...spaceOf(thread.spaceId),
|
|
messageId: latest?.id ?? null,
|
|
text: previewText(latest?.blocks),
|
|
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
|
|
});
|
|
}
|
|
|
|
return [...waiting.sort(byCreatedAt), ...unread.sort(byCreatedAt)];
|
|
}
|
|
|
|
const botSelect = { id: true, name: true, color: true } as const;
|
|
|
|
/**
|
|
* The other spaces whose items the inbox shows. The home space's main conversation collects every
|
|
* space the user belongs to, except spaces being deleted; a project's main conversation keeps to
|
|
* its own project.
|
|
*/
|
|
async function otherInboxSpaces(
|
|
prisma: PrismaClient,
|
|
actor: Actor,
|
|
): Promise<Map<string, InboxSpace>> {
|
|
const current = await prisma.space.findUnique({
|
|
where: { id: actor.spaceId },
|
|
select: { isDefault: true },
|
|
});
|
|
if (!current?.isDefault) return new Map();
|
|
const memberships = await prisma.spaceMember.findMany({
|
|
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
|
|
select: { space: { select: { id: true, name: true } } },
|
|
});
|
|
return new Map(memberships.map(({ space }) => [space.id, space]));
|
|
}
|
|
|
|
/**
|
|
* The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are
|
|
* answered and opened in their own space, where the thread procedures check membership and
|
|
* ownership again.
|
|
*/
|
|
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
|
|
const otherSpaces = await otherInboxSpaces(prisma, actor);
|
|
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
|
|
const ownBot = { userId: actor.userId, archivedAt: null };
|
|
const threadSelect = {
|
|
botId: true,
|
|
groupId: true,
|
|
group: { select: { kind: true, archivedAt: true } },
|
|
} as const;
|
|
const [runs, unreadThreads, suggestionEffects] = await Promise.all([
|
|
prisma.run.findMany({
|
|
where: {
|
|
spaceId: { in: spaceIds },
|
|
userId: actor.userId,
|
|
status: { in: ["waiting_input", "waiting_takeover"] },
|
|
bot: ownBot,
|
|
thread: { userId: actor.userId },
|
|
},
|
|
select: {
|
|
id: true,
|
|
spaceId: true,
|
|
status: true,
|
|
threadId: true,
|
|
updatedAt: true,
|
|
bot: { select: botSelect },
|
|
thread: { select: threadSelect },
|
|
},
|
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
|
take: INBOX_RUN_LIMIT,
|
|
}),
|
|
// Only specialists' own threads; group threads, the main conversation included, stay out.
|
|
prisma.thread.findMany({
|
|
where: {
|
|
spaceId: { in: spaceIds },
|
|
userId: actor.userId,
|
|
unread: true,
|
|
bot: { ...ownBot, kind: "specialist" },
|
|
},
|
|
select: {
|
|
spaceId: true,
|
|
createdAt: true,
|
|
bot: { select: botSelect },
|
|
messages: {
|
|
where: { role: "bot" },
|
|
orderBy: { seq: "desc" },
|
|
take: 1,
|
|
select: { id: true, blocks: true, createdAt: true },
|
|
},
|
|
},
|
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
take: INBOX_UNREAD_LIMIT,
|
|
}),
|
|
prisma.externalEffect.findMany({
|
|
where: {
|
|
spaceId: { in: spaceIds },
|
|
kind: "suggestion",
|
|
status: "proposed",
|
|
createdAt: { gt: new Date(Date.now() - SUGGESTION_TTL_MS) },
|
|
run: { userId: actor.userId, bot: ownBot, thread: { userId: actor.userId } },
|
|
},
|
|
select: {
|
|
id: true,
|
|
spaceId: true,
|
|
createdAt: true,
|
|
request: true,
|
|
run: { select: { bot: { select: botSelect }, thread: { select: threadSelect } } },
|
|
},
|
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
take: INBOX_RUN_LIMIT,
|
|
}),
|
|
]);
|
|
const cardMessageIds = new Map<string, string>();
|
|
for (const effect of suggestionEffects) {
|
|
const messageId = (effect.request as { card?: { messageId?: unknown } } | null)?.card
|
|
?.messageId;
|
|
if (typeof messageId === "string") cardMessageIds.set(effect.id, messageId);
|
|
}
|
|
const cardMessages =
|
|
cardMessageIds.size === 0
|
|
? []
|
|
: await prisma.message.findMany({
|
|
where: { id: { in: [...cardMessageIds.values()] }, role: "bot" },
|
|
select: { id: true, blocks: true },
|
|
});
|
|
const blocksById = new Map(cardMessages.map((message) => [message.id, message.blocks]));
|
|
const suggestions: InboxSuggestionRow[] = suggestionEffects.flatMap((effect) => {
|
|
const messageId = cardMessageIds.get(effect.id);
|
|
if (!messageId || !blocksById.has(messageId)) return [];
|
|
return [
|
|
{
|
|
spaceId: effect.spaceId,
|
|
createdAt: effect.createdAt,
|
|
bot: effect.run.bot,
|
|
thread: effect.run.thread,
|
|
messageId,
|
|
cardId: effect.id,
|
|
blocks: blocksById.get(messageId),
|
|
},
|
|
];
|
|
});
|
|
const messages =
|
|
runs.length === 0
|
|
? []
|
|
: await prisma.message.findMany({
|
|
where: {
|
|
threadId: { in: [...new Set(runs.map((run) => run.threadId))] },
|
|
runId: { in: runs.map((run) => run.id) },
|
|
role: "bot",
|
|
OR: [
|
|
{ blocks: { array_contains: [{ kind: "ask" }] } },
|
|
{ blocks: { array_contains: [{ kind: "computer" }] } },
|
|
],
|
|
},
|
|
orderBy: { seq: "desc" },
|
|
select: { id: true, runId: true, blocks: true, createdAt: true },
|
|
});
|
|
return buildInboxItems({ runs, messages, unreadThreads, suggestions, otherSpaces });
|
|
}
|