Files
growqr-backend/src/actors/grow/grow-actor.ts

194 lines
7.0 KiB
TypeScript

import { actor, event } from "rivetkit";
import type { CreateConversationInput, GrowActorState, GrowConversation, SetupGrowInput } from "./types.js";
import type { GrowActiveMission } from "../missions/types.js";
const buildId = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const now = () => Date.now();
function defaultTitle(index: number) {
return index === 0 ? "Talk to Me" : `Conversation ${index + 1}`;
}
function ensureInitialized(state: GrowActorState) {
if (!state.userId) throw new Error("Grow actor is not initialized");
}
function normalizeState(state: GrowActorState) {
state.conversations ??= [];
state.activeConversationId ??= null;
state.activeMissions ??= [];
state.createdAt ??= now();
state.updatedAt ??= now();
}
function createConversationRecord(state: GrowActorState, input: CreateConversationInput = {}): GrowConversation {
const timestamp = now();
return {
id: buildId("conversation"),
title: input.title?.trim() || defaultTitle(state.conversations.length),
createdAt: timestamp,
updatedAt: timestamp,
};
}
export const growActor = actor({
state: {
userId: "",
conversations: [],
activeConversationId: null,
activeMissions: [],
createdAt: Date.now(),
updatedAt: Date.now(),
} as GrowActorState,
events: {
ready: event<{ userId: string; conversationId: string }>(),
conversationCreated: event<GrowConversation>(),
conversationReset: event<GrowConversation>(),
missionActivated: event<GrowActiveMission>(),
missionUpdated: event<GrowActiveMission>(),
},
actions: {
setup: async (c, input: SetupGrowInput) => {
if (c.state.userId && c.state.userId !== input.userId) {
throw new Error("Grow actor already bound to a different user");
}
normalizeState(c.state);
c.state.userId = input.userId;
if (!c.state.conversations.length) {
const conversation = createConversationRecord(c.state, { title: "Talk to Me" });
c.state.conversations.push(conversation);
c.state.activeConversationId = conversation.id;
c.broadcast("conversationCreated", conversation);
} else if (!c.state.activeConversationId) {
c.state.activeConversationId = c.state.conversations[0]?.id ?? null;
}
const activeConversationId = c.state.activeConversationId;
if (!activeConversationId) throw new Error("Grow actor has no active conversation");
c.state.updatedAt = now();
c.broadcast("ready", {
userId: c.state.userId,
conversationId: activeConversationId,
});
return {
userId: c.state.userId,
activeConversationId: c.state.activeConversationId,
conversations: c.state.conversations,
activeMissions: c.state.activeMissions,
};
},
getState: async (c) => {
normalizeState(c.state);
ensureInitialized(c.state);
return {
userId: c.state.userId,
activeConversationId: c.state.activeConversationId,
conversations: c.state.conversations,
activeMissions: c.state.activeMissions,
};
},
createConversation: async (c, input: CreateConversationInput = {}) => {
normalizeState(c.state);
ensureInitialized(c.state);
const conversation = createConversationRecord(c.state, input);
c.state.conversations.unshift(conversation);
c.state.activeConversationId = conversation.id;
c.state.updatedAt = conversation.updatedAt;
c.broadcast("conversationCreated", conversation);
return conversation;
},
listConversations: async (c) => {
normalizeState(c.state);
ensureInitialized(c.state);
return c.state.conversations;
},
getConversation: async (c, input: { conversationId: string }) => {
ensureInitialized(c.state);
return c.state.conversations.find((conversation) => conversation.id === input.conversationId) ?? null;
},
touchConversation: async (c, input: { conversationId: string; title?: string }) => {
ensureInitialized(c.state);
const conversation = c.state.conversations.find((item) => item.id === input.conversationId);
if (!conversation) throw new Error(`Unknown conversation: ${input.conversationId}`);
if (input.title?.trim()) conversation.title = input.title.trim();
conversation.updatedAt = now();
c.state.activeConversationId = conversation.id;
c.state.updatedAt = conversation.updatedAt;
return conversation;
},
resetConversation: async (c, input: { conversationId?: string; title?: string } = {}) => {
ensureInitialized(c.state);
const conversationId = input.conversationId ?? c.state.activeConversationId;
if (!conversationId) {
const created = createConversationRecord(c.state, { title: input.title ?? "Talk to Me" });
c.state.conversations.unshift(created);
c.state.activeConversationId = created.id;
c.state.updatedAt = created.updatedAt;
c.broadcast("conversationCreated", created);
return created;
}
const conversation = c.state.conversations.find((item) => item.id === conversationId);
if (!conversation) throw new Error(`Unknown conversation: ${conversationId}`);
conversation.title = input.title?.trim() || conversation.title;
conversation.updatedAt = now();
c.state.activeConversationId = conversation.id;
c.state.updatedAt = conversation.updatedAt;
c.broadcast("conversationReset", conversation);
return conversation;
},
registerActiveMission: async (c, input: GrowActiveMission) => {
normalizeState(c.state);
ensureInitialized(c.state);
const existingIndex = c.state.activeMissions.findIndex((mission) => mission.instanceId === input.instanceId);
const record = { ...input, updatedAt: now() };
if (existingIndex >= 0) {
c.state.activeMissions[existingIndex] = record;
c.broadcast("missionUpdated", record);
} else {
c.state.activeMissions.unshift(record);
c.broadcast("missionActivated", record);
}
c.state.updatedAt = record.updatedAt;
return record;
},
updateActiveMission: async (c, input: Pick<GrowActiveMission, "instanceId"> & Partial<GrowActiveMission>) => {
normalizeState(c.state);
ensureInitialized(c.state);
const mission = c.state.activeMissions.find((item) => item.instanceId === input.instanceId);
if (!mission) throw new Error(`Unknown active mission: ${input.instanceId}`);
Object.assign(mission, input, { updatedAt: now() });
c.state.updatedAt = mission.updatedAt;
c.broadcast("missionUpdated", mission);
return mission;
},
listActiveMissions: async (c) => {
normalizeState(c.state);
ensureInitialized(c.state);
return c.state.activeMissions;
},
getActiveMission: async (c, input: { instanceId: string }) => {
normalizeState(c.state);
ensureInitialized(c.state);
return c.state.activeMissions.find((mission) => mission.instanceId === input.instanceId) ?? null;
},
},
});