diff --git a/.env.example b/.env.example index 738995cc..70b59820 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,10 @@ GOOGLE_CLIENT_SECRET="" # out on its next minute. # AGENT_URL="http://127.0.0.1:2000" +# Port used by the self-hosted Eve service. Hosted runtimes can keep injecting +# their standard PORT variable instead. +# AGENT_PORT="2000" + # Lets a signed-in rep talk to the agent from the contact sheet. # # The browser never calls the agent directly. The app proxies /eve/v1/* on its diff --git a/AGENTS.md b/AGENTS.md index fcac145a..930249a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,54 @@ rules and skills you read. ## Design @docs/design.md + +## Median Tasks + +Median can use a project-local workspace binding. If this repository has +`.median/config.json`, run `mdn` commands from inside this repository so the +correct Median workspace profile is selected. The local config stores only a +profile name; API keys stay in your user config. + +To bind this repository to a workspace: + +``` +mdn setup --local +``` + +Before starting work, check your assigned tasks: + +``` +mdn tasks --agent +``` + +When picking up a task: + +``` +mdn status in_progress --agent +``` + +When completing a task: + +``` +mdn status ready --agent +``` + +To create a new task: + +``` +mdn create --title "Description" --status todo --priority medium --agent +``` + +## Commit Messages & Pull Requests + +Always include the Median task ID in commit messages and PR titles so tasks get marked automatically. + +``` +git commit -m "MDN-42 fix: resolve auth token expiry" +``` + +For pull requests, include the task ID in the title: + +``` +MDN-42 fix: resolve auth token expiry +``` diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c2..16f00e26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,53 @@ @AGENTS.md + + +## Median Tasks + +Median can use a project-local workspace binding. If this repository has +`.median/config.json`, run `mdn` commands from inside this repository so the +correct Median workspace profile is selected. The local config stores only a +profile name; API keys stay in your user config. + +To bind this repository to a workspace: + +``` +mdn setup --local +``` + +Before starting work, check your assigned tasks: + +``` +mdn tasks --agent +``` + +When picking up a task: + +``` +mdn status in_progress --agent +``` + +When completing a task: + +``` +mdn status ready --agent +``` + +To create a new task: + +``` +mdn create --title "Description" --status todo --priority medium --agent +``` + +## Commit Messages & Pull Requests + +Always include the Median task ID in commit messages and PR titles so tasks get marked automatically. + +``` +git commit -m "MDN-42 fix: resolve auth token expiry" +``` + +For pull requests, include the task ID in the title: + +``` +MDN-42 fix: resolve auth token expiry +``` diff --git a/README.md b/README.md index 4a43086b..3dec07d5 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ short version: | Command | | | --- | --- | -| `bun run dev` | Everything, in watch mode | +| `bun run dev` | Prepare the local database, then run everything in dependency-aware watch mode | | `bun run build` | Build all apps and packages | | `bun run test` | Run the test suite | | `bun run check-types` | `tsc --noEmit` everywhere | diff --git a/apps/agent/agent/agent.ts b/apps/agent/agent/agent.ts index de358c1f..56756af5 100644 --- a/apps/agent/agent/agent.ts +++ b/apps/agent/agent/agent.ts @@ -17,4 +17,9 @@ export default defineAgent({ fallback: DEFAULT_AGENT_MODEL.id, events: { "session.started": () => selectedModel() }, }), + limits: { + maxInputTokensPerSession: 500_000, + maxOutputTokensPerSession: 50_000, + sessionTimeoutMs: 30 * 24 * 60 * 60 * 1000, + }, }); diff --git a/apps/agent/agent/channels/crm.ts b/apps/agent/agent/channels/crm.ts index 44d686ac..57aae03c 100644 --- a/apps/agent/agent/channels/crm.ts +++ b/apps/agent/agent/channels/crm.ts @@ -1,8 +1,19 @@ +import { timingSafeEqual } from "node:crypto"; import { EnrichmentStatus } from "@crm/db"; import { defineChannel, POST } from "eve/channels"; import { verifyKey } from "../lib/context-dev"; +import { + builderIdFromToken, + dispatchAgentRun, + dispatchBuilderSubmission, + drainAgentRuns, + drainBuilder, + failRun, + runIdFromToken, +} from "../lib/custom-agent-dispatch"; import { brief, drainAll, taskAuth } from "../lib/dispatch"; import { settle } from "../lib/enrichment"; +import { finishRun } from "../lib/run-runtime"; import { completeTask, taskSubject } from "../lib/tasks"; const TASK_MARKER = "task:"; @@ -10,8 +21,13 @@ const TASK_MARKER = "task:"; function authorised(request: Request): boolean { const secret = process.env.AGENT_BRIDGE_SECRET?.trim(); if (!secret) return false; + const header = request.headers.get("authorization"); + if (!header?.startsWith("Bearer ")) return false; + const candidate = Buffer.from(header.slice("Bearer ".length)); + const expected = Buffer.from(secret); + if (candidate.length !== expected.length) return false; - return request.headers.get("authorization") === `Bearer ${secret}`; + return timingSafeEqual(candidate, expected); } export function taskToken(taskId: string): string { @@ -47,6 +63,30 @@ export default defineChannel({ return new Response(null, { status: 202 }); }), + POST( + "/internal/crm/builder-dispatch", + async (request, { send, waitUntil }) => { + if (!authorised(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + waitUntil(drainBuilder(send)); + return new Response(null, { status: 202 }); + }, + ), + + POST( + "/internal/crm/agent-dispatch", + async (request, { send, waitUntil }) => { + if (!authorised(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + waitUntil(drainAgentRuns(send)); + return new Response(null, { status: 202 }); + }, + ), + POST("/internal/crm/verify-key", async (request) => { if (!authorised(request)) { return new Response("Unauthorized", { status: 401 }); @@ -71,29 +111,107 @@ export default defineChannel({ ], events: { + async "message.completed"(data, channel) { + const conversationId = builderIdFromToken(channel.continuationToken); + if (!conversationId || !data.message?.trim()) return; + + await import("@crm/db").then(({ db }) => + db.agentConversation.updateMany({ + where: { id: conversationId, kind: "BUILDER" }, + data: { + lastAssistantAt: new Date(), + lastMessageAt: new Date(), + messageCount: { increment: 1 }, + }, + }), + ); + }, + async "session.waiting"(_data, channel) { const taskId = taskFromToken(channel.continuationToken); - if (!taskId) return; + if (taskId) { + const subject = await completeTask(taskId, "ran"); + if (subject) await settle(subject, EnrichmentStatus.COMPLETE); + return; + } - const subject = await completeTask(taskId, "ran"); - if (subject) await settle(subject, EnrichmentStatus.COMPLETE); + const conversationId = builderIdFromToken(channel.continuationToken); + if (!conversationId) return; + + await import("@crm/db").then(({ db }) => + db.agentConversation.updateMany({ + where: { id: conversationId, kind: "BUILDER" }, + data: { continuationToken: channel.continuationToken }, + }), + ); }, async "turn.failed"(data, channel) { const taskId = taskFromToken(channel.continuationToken); - if (!taskId) return; - const reason = - typeof data === "object" && data && "error" in data - ? String((data as { error: unknown }).error) - : "The research turn failed."; + typeof data === "object" && data && "message" in data + ? String((data as { message: unknown }).message) + : "The agent turn failed."; + + if (taskId) { + const subject = await taskSubject(taskId); + if (subject) await settle(subject, EnrichmentStatus.FAILED, reason); + return; + } - const subject = await taskSubject(taskId); - if (subject) await settle(subject, EnrichmentStatus.FAILED, reason); + const runId = runIdFromToken(channel.continuationToken); + if (runId) await failRun(runId, "TURN_FAILED", reason); + }, + + async "session.completed"(_data, channel) { + const runId = runIdFromToken(channel.continuationToken); + if (!runId) return; + + const { db } = await import("@crm/db"); + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { status: true, summary: true }, + }); + if (run?.status === "RUNNING") { + await finishRun(runId, { + summary: run.summary ?? "The agent run completed.", + }); + } + }, + + async "session.failed"(data, channel) { + const conversationId = builderIdFromToken(channel.continuationToken); + if (conversationId) { + const { db } = await import("@crm/db"); + await db.agentConversation.updateMany({ + where: { id: conversationId, kind: "BUILDER" }, + data: { + continuationToken: channel.continuationToken, + lastAssistantAt: new Date(), + lastMessageAt: new Date(), + }, + }); + return; + } + + const runId = runIdFromToken(channel.continuationToken); + if (runId) await failRun(runId, data.code, data.message); }, }, async receive(input, { send }) { + const builderSubmissionId = + typeof input.target?.builderSubmissionId === "string" + ? input.target.builderSubmissionId + : null; + if (builderSubmissionId) { + return dispatchBuilderSubmission(builderSubmissionId, send); + } + + const runId = + typeof input.target?.runId === "string" ? input.target.runId : null; + if (runId) return dispatchAgentRun(runId, send); + const taskId = typeof input.target?.taskId === "string" ? input.target.taskId : null; diff --git a/apps/agent/agent/hooks/audit.ts b/apps/agent/agent/hooks/audit.ts index 0a363768..256ec03d 100644 --- a/apps/agent/agent/hooks/audit.ts +++ b/apps/agent/agent/hooks/audit.ts @@ -1,6 +1,8 @@ -import { db } from "@crm/db"; +import { db, type Prisma } from "@crm/db"; import { defineHook } from "eve/hooks"; import { currentFocus } from "../lib/focus"; +import { lockAgentRun } from "../lib/run-state"; +import { attribute, purposeOf } from "../lib/session-purpose"; const CUMULATIVE_DELTAS = new Set(["reasoning.appended"]); @@ -12,18 +14,30 @@ export default defineHook({ if (!id || CUMULATIVE_DELTAS.has(event.type)) return; try { - await db.agentEvent.createMany({ - data: [ - { - id, - sessionId: ctx.session.id, - contactId: currentFocus().contactId, - type: event.type, - data: ("data" in event ? (event.data ?? {}) : {}) as object, - emittedAt: event.meta?.at ? new Date(event.meta.at) : new Date(), - }, - ], - skipDuplicates: true, + const data = ("data" in event ? (event.data ?? {}) : {}) as object; + const emittedAt = event.meta?.at ? new Date(event.meta.at) : new Date(); + await db.$transaction(async (tx) => { + await tx.agentEvent.createMany({ + data: [ + { + id, + sessionId: ctx.session.id, + contactId: currentFocus().contactId, + type: event.type, + data, + emittedAt, + }, + ], + skipDuplicates: true, + }); + + const purpose = purposeOf(ctx); + if (purpose === "builder") { + await persistBuilderLifecycle(tx, event, ctx.session.id, ctx); + } + if (purpose === "team-agent") { + await persistRunEvent(tx, id, event.type, data, emittedAt, ctx); + } }); } catch (error) { console.warn("[audit] could not record event", { @@ -34,3 +48,124 @@ export default defineHook({ }, }, }); + +async function persistBuilderLifecycle( + tx: Prisma.TransactionClient, + event: { type: string }, + sessionId: string, + ctx: Parameters[0], +) { + const conversationId = attribute(ctx, "conversationId"); + if (!conversationId) return; + + if (event.type === "session.started") { + await tx.agentConversation.updateMany({ + where: { id: conversationId, kind: "BUILDER" }, + data: { sessionId, continuationToken: null }, + }); + } + + if (event.type === "message.received") { + const submissionId = attribute(ctx, "submissionId"); + if (submissionId) { + await tx.agentConversationSubmission.updateMany({ + where: { id: submissionId, conversationId }, + data: { status: "ACCEPTED", acceptedAt: new Date() }, + }); + } + } +} + +async function persistRunEvent( + tx: Prisma.TransactionClient, + eventId: string, + type: string, + data: object, + emittedAt: Date, + ctx: Parameters[0] & { session: { id: string } }, +) { + const runId = attribute(ctx, "runId"); + if (!runId) return; + + const run = await lockAgentRun(tx, runId); + const existing = await tx.agentRunEvent.findUnique({ + where: { id: eventId }, + select: { id: true }, + }); + if (existing) return; + + const sequence = run.nextEventSequence + 1; + const mayStart = + type === "session.started" && + (run.status === "QUEUED" || run.status === "RUNNING"); + await tx.agentRun.update({ + where: { id: run.id }, + data: { + nextEventSequence: sequence, + ...(mayStart + ? { + sessionId: ctx.session.id, + status: "RUNNING", + startedAt: run.startedAt ?? new Date(), + } + : {}), + }, + }); + + await tx.agentRunEvent.create({ + data: { + id: eventId, + runId, + sequence, + type, + data: data as Prisma.InputJsonValue, + emittedAt, + }, + }); + + if (type === "step.completed") { + const usage = recordOf(data).usage; + const values = recordOf(usage); + const inputTokens = numberOf(values.inputTokens); + const outputTokens = numberOf(values.outputTokens); + const costUsd = numberOf(values.costUsd); + const current = await tx.agentRun.findUniqueOrThrow({ + where: { id: runId }, + select: { inputTokens: true, outputTokens: true, costUsd: true }, + }); + await tx.agentRun.update({ + where: { id: runId }, + data: { + ...(inputTokens !== null + ? { inputTokens: (current.inputTokens ?? 0) + inputTokens } + : {}), + ...(outputTokens !== null + ? { outputTokens: (current.outputTokens ?? 0) + outputTokens } + : {}), + ...(costUsd !== null + ? { costUsd: Number(current.costUsd ?? 0) + costUsd } + : {}), + }, + }); + } + + if (type === "message.completed") { + const message = recordOf(data).message; + if (typeof message === "string" && message.trim()) { + await tx.agentRun.updateMany({ + where: { id: runId, status: "RUNNING" }, + data: { summary: message.slice(0, 1000) }, + }); + } + } +} + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function numberOf(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/apps/agent/agent/instructions.md b/apps/agent/agent/instructions.md index 4aa0ab64..90381f98 100644 --- a/apps/agent/agent/instructions.md +++ b/apps/agent/agent/instructions.md @@ -1,111 +1,9 @@ -# CRM research agent +# Comp AI CRM agent runtime -You work out who the people in our CRM are, what the companies are, and where -the deals stand — so a rep opens a record already knowing what they are dealing -with. +You are the durable Eve runtime behind Comp AI CRM. The session-specific +instructions identify the only purpose of the current session. Follow that +purpose exactly and do not borrow tools or behavior from another purpose. -## The one rule - -**Never write a fact you have not read from a source.** - -Most contacts here arrived as an email address and a guess. -`pmarchetti@fernhill.com` became a contact called "Pmarchetti" because that is what the -address looks like title-cased. Your job is to replace that with something true, -not with something that reads better. - -A confidently wrong fact is worse than a missing one, because nobody can tell it -is wrong. If you cannot confirm something, leave it. That is a successful -outcome. - -## How this works - -You do not assert confidence — you report **evidence**, and the ledger scores -it. `record_fact` takes what you *saw* ("their signature block says Head of -Security"), decides what that is worth, and either writes the record or offers a -rep a suggestion. Strong evidence writes. Weak evidence becomes a question for a -human. Both are the system working. - -So there is nothing to argue with and no bar to clear by trying harder. Report -what you found, accurately, and move on. - -## The record you were opened on - -Every session starts from one record, and your session instructions say which -and give you its id. Read that record before anything else: - -| Opened on | Start with | -| --------- | --------------------- | -| a person | `read_crm_history` | -| a company | `read_company_history`| -| a deal | `read_deal_history` | - -All three are free — our own database, no vendor, no budget — and they are the -best evidence in the system besides. - -The one session that opens on no record is the one that writes up **the company -you work for**. Your instructions name our own website; read it and call -`write_workspace_profile`. Everything you write there is read back to you at the -start of every other session, which is why it is kept short. - -## The three records are joined, and so are your tools - -A contact works somewhere. A company has people and deals. A deal has a company -and the people on it. **You can always get from any one to the others**, and -each read hands you the ids to do it: - -- `read_crm_history` returns the contact's **company id** and the deals they are - on. -- `read_company_history` returns **every contact there, with their ids**, and - every deal. -- `read_deal_history` returns the company and everyone attached, with ids. -- `search_crm` finds any of the three by name, email address or domain. - -So two answers are always wrong: - -**"I don't have a tool that lists contacts by company."** You do. It is -`read_company_history`, and the person asking is looking at that company. - -**"Could you paste the contact's name or email address?"** Never ask a rep for -an id, and never ask them to search for you. Call `search_crm`. If it returns -nothing, say so — that is a real answer. If it returns four Marchettis, name all -four with their titles and ask which one they mean; choosing between candidates -is a question, and pasting a cuid is a chore. - -## Where to look outside, in order - -1. **The CRM first, always.** A reply from their own address, a signature block, - a meeting they attended. No data vendor can sell us any of that. -2. **LinkedIn** (`resolve_linkedin_profile` → `get_linkedin_profile`) for - identity: name, current title, employer, tenure. Self-reported, and - authoritative for who someone is. -3. **The open web** (`web_search`, `web_fetch`, `research_person`, - `research_company`) for context: news, funding, what they have said publicly. - Sometimes wrong about job titles — where it disagrees with LinkedIn about - identity, LinkedIn wins. - -Search results are not evidence. A search for "Paula Marchetti" once returned -Brightwater's CEO. A search tells you where to look. - -**Not every install has 2 and 3.** They each need an API key, and plenty of -copies of this CRM run with none. Your session instructions list what this one -has before you plan; a tool whose source is missing says so, costs nothing, and -will say the same thing however many times you call it. This is normal, not -broken. Step 1 needs no key, it is the strongest evidence anyway, and a record -that says only what the mailbox proves is a good outcome. - -## Your budget - -Each session comes with a research budget, and **only vendor calls spend it**. -Every read of our own CRM is free, however many you make. When the budget is -gone, write up what you have and stop — or call `schedule_recheck` with a reason -if it is worth another look later. Running out is not a failure; spending it all -on somebody nobody is selling to is. - -## Skills - -Load these when the work calls for them, and before your first one of a session: - -- `identity-matching` — deciding whether a candidate really is this person. -- `evidence` — which observation is which `kind`, and why it matters. -- `writing-a-brief` — the Background panel a rep reads before a call. -- `data-boundaries` — what you may read (everything) and what may leave. +Never invent a CRM record, connected integration, completed action, or external +side effect. Tools and persisted state are the authority for what exists and +what happened. diff --git a/apps/agent/agent/instructions/task.ts b/apps/agent/agent/instructions/task.ts index 186ac5b3..a22b7afd 100644 --- a/apps/agent/agent/instructions/task.ts +++ b/apps/agent/agent/instructions/task.ts @@ -1,10 +1,23 @@ import { defineDynamic, defineInstructions } from "eve/instructions"; import { focusOn, setBudget } from "../lib/focus"; import { sessionPreamble } from "../lib/preamble"; +import { RESEARCH_INSTRUCTIONS } from "../lib/research-instructions"; +import { attribute, purposeOf } from "../lib/session-purpose"; export default defineDynamic({ events: { "session.started": async (_event, ctx) => { + const purpose = purposeOf(ctx); + if (purpose === "builder") { + return builderInstructions(ctx); + } + + if (purpose === "team-agent") { + return defineInstructions({ + markdown: `This is one background run of a deployed team agent. Call agent_runner exactly once and pass the run id from your user message. Do not call research tools or perform work yourself. Relay the specialist's structured factual completion summary. Never claim an external action that the specialist did not log.`, + }); + } + const attributes = ctx.session.auth.current?.attributes ?? {}; const budget = asNumber(attributes.budget); const kind = asString(attributes.taskKind); @@ -27,11 +40,38 @@ export default defineDynamic({ focusOn({ ...focus, sessionId: ctx.session.id }); - return defineInstructions({ markdown }); + return defineInstructions({ + markdown: `${RESEARCH_INSTRUCTIONS}\n\n${markdown}`, + }); }, + "turn.started": (_event, ctx) => + purposeOf(ctx) === "builder" ? builderInstructions(ctx) : null, }, }); +function builderInstructions(ctx: Parameters[0]) { + return defineInstructions({ + markdown: builderTaskMarkdown( + attribute(ctx, "commandType"), + attribute(ctx, "needsTitle") === "true", + ), + }); +} + +export function builderTaskMarkdown( + commandType: string | null, + needsTitle = false, +): string { + const task = + commandType === "CREATE_AGENT" + ? `This private CRM chat turn is authorized to create or revise an agent. Call agent_builder exactly once. Pass the complete request, the conversation's relevant decisions, every tagged resource, and your understanding of any attachment. Do not call research tools or mutate CRM records yourself. If the specialist returns needs_input, call ask_question with exactly its question, options, and freeform policy instead of replying with a plain-text question. Ask exactly one decision at a time and never bundle several missing details into one prompt. After the answer, ask another question only if the build remains materially blocked. Ask only when the answer materially changes the trigger, records, integrations, schedule, outcome, or side effect. Do not interrupt a sufficiently specific request or ask about optional polish. If the specialist returns draft_ready, relay its concise summary and explain that the draft is ready for human review and is not deployed yet.` + : `This is a private CRM assistant chat. Answer the user's question directly. Use tagged records as scope and use available read-only CRM and research tools when evidence is needed. Use list_deals for pipeline-wide, open-deal, or inactivity questions and follow its pagination until the requested scope is complete. The chat renders list_deals output as a structured deal list. Do not restate or enumerate individual deal rows in prose, bullets, or tables; the structured list is the sole row-level presentation. Give only a concise synthesis, caveats, and useful next actions after the tool results. If one materially necessary decision is missing, call ask_question with one focused follow-up instead of guessing; do not interrupt for optional detail. Do not call agent_builder, create an agent draft, or mutate CRM records on this turn. Agent creation begins only from an explicit request to create or build one. Be concise, distinguish CRM evidence from inference, and say when the CRM does not contain the answer.`; + + return needsTitle + ? `Before any other work, call set_chat_title once. Summarize the user's first message as a polished title of three to seven words in sentence case. Capture the intent, remove slash-command syntax and filler, and do not use quotation marks or ending punctuation.\n\n${task}` + : task; +} + function asString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } diff --git a/apps/agent/agent/lib/accounts.ts b/apps/agent/agent/lib/accounts.ts index 45406d29..459c39b2 100644 --- a/apps/agent/agent/lib/accounts.ts +++ b/apps/agent/agent/lib/accounts.ts @@ -90,6 +90,8 @@ export async function readCompanyHistory( threads?: number; messagesPerThread?: number; people?: number; + includeEmail?: boolean; + includeCalendar?: boolean; } = {}, ): Promise { const company = await db.company.findUnique({ @@ -110,6 +112,8 @@ export async function readCompanyHistory( }); if (!company) return null; + const includeEmail = options.includeEmail ?? true; + const includeCalendar = options.includeCalendar ?? true; const belongsToCompany = { OR: [{ companyId }, { contact: { companyId } }] }; @@ -127,7 +131,15 @@ export async function readCompanyHistory( email: true, linkedinUrl: true, lastActivityAt: true, - _count: { select: { emailThreads: true, calendarEvents: true } }, + _count: + includeEmail || includeCalendar + ? { + select: { + emailThreads: includeEmail, + calendarEvents: includeCalendar, + }, + } + : false, }, }), db.deal.findMany({ @@ -152,62 +164,74 @@ export async function readCompanyHistory( }, }, }), - db.emailThread.findMany({ - where: belongsToCompany, - orderBy: { lastMessageAt: "desc" }, - take: options.threads ?? 5, - select: { - subject: true, - messageCount: true, - lastMessageAt: true, - contact: { select: { id: true, firstName: true, lastName: true } }, - messages: { - orderBy: { sentAt: "desc" }, - take: options.messagesPerThread ?? 4, + includeEmail + ? db.emailThread.findMany({ + where: belongsToCompany, + orderBy: { lastMessageAt: "desc" }, + take: options.threads ?? 5, select: { - direction: true, - fromEmail: true, - fromName: true, - sentAt: true, - body: true, - snippet: true, + subject: true, + messageCount: true, + lastMessageAt: true, + contact: { + select: { id: true, firstName: true, lastName: true }, + }, + messages: { + orderBy: { sentAt: "desc" }, + take: options.messagesPerThread ?? 4, + select: { + direction: true, + fromEmail: true, + fromName: true, + sentAt: true, + body: true, + snippet: true, + }, + }, }, - }, - }, - }), - db.calendarEvent.findMany({ - where: { - OR: [ - { companyId }, - { contact: { companyId } }, - { attendees: { some: { contact: { companyId } } } }, - ], - }, - orderBy: { startsAt: "desc" }, - take: 10, - select: { - title: true, - startsAt: true, - attendees: { select: { email: true, name: true } }, - }, - }), + }) + : Promise.resolve([]), + includeCalendar + ? db.calendarEvent.findMany({ + where: { + OR: [ + { companyId }, + { contact: { companyId } }, + { attendees: { some: { contact: { companyId } } } }, + ], + }, + orderBy: { startsAt: "desc" }, + take: 10, + select: { + title: true, + startsAt: true, + attendees: { select: { email: true, name: true } }, + }, + }) + : Promise.resolve([]), recentNotes({ companyId }), - db.emailMessage.findFirst({ - where: { - direction: EmailDirection.INBOUND, - thread: belongsToCompany, - }, - orderBy: { sentAt: "desc" }, - select: { sentAt: true, fromEmail: true, fromName: true }, - }), + includeEmail + ? db.emailMessage.findFirst({ + where: { + direction: EmailDirection.INBOUND, + thread: belongsToCompany, + }, + orderBy: { sentAt: "desc" }, + select: { sentAt: true, fromEmail: true, fromName: true }, + }) + : Promise.resolve(null), Promise.all([ db.contact.count({ where: { companyId } }), - db.emailMessage.count({ where: { thread: belongsToCompany } }), - db.calendarEvent.count({ - where: { - OR: [{ companyId }, { contact: { companyId } }], - }, - }), + includeEmail + ? db.emailMessage.count({ where: { thread: belongsToCompany } }) + : Promise.resolve(0), + includeCalendar + ? db.calendarEvent.count({ + where: { + OR: [{ companyId }, { contact: { companyId } }], + }, + }) + : Promise.resolve(0), ]), ]); @@ -235,8 +259,8 @@ export async function readCompanyHistory( email: person.email, linkedinUrl: person.linkedinUrl, lastActivityAt: person.lastActivityAt?.toISOString() ?? null, - threads: person._count.emailThreads, - meetings: person._count.calendarEvents, + threads: person._count?.emailThreads ?? 0, + meetings: person._count?.calendarEvents ?? 0, needsIdentity: isDerivedName( person.email, person.firstName, @@ -307,7 +331,12 @@ export type DealHistory = { export async function readDealHistory( dealId: string, - options: { threads?: number; messagesPerThread?: number } = {}, + options: { + threads?: number; + messagesPerThread?: number; + includeEmail?: boolean; + includeCalendar?: boolean; + } = {}, ): Promise { const deal = await db.deal.findUnique({ where: { id: dealId }, @@ -344,6 +373,8 @@ export async function readDealHistory( }); if (!deal) return null; + const includeEmail = options.includeEmail ?? true; + const includeCalendar = options.includeCalendar ?? true; const contactIds = deal.contacts.map(({ contact }) => contact.id); @@ -365,54 +396,67 @@ export async function readDealHistory( take: 25, select: { meta: true, createdAt: true }, }), - db.emailThread.findMany({ - where: relatedThreads, - orderBy: { lastMessageAt: "desc" }, - take: options.threads ?? 5, - select: { - subject: true, - messageCount: true, - lastMessageAt: true, - contact: { select: { id: true, firstName: true, lastName: true } }, - messages: { - orderBy: { sentAt: "desc" }, - take: options.messagesPerThread ?? 4, + includeEmail + ? db.emailThread.findMany({ + where: relatedThreads, + orderBy: { lastMessageAt: "desc" }, + take: options.threads ?? 5, select: { - direction: true, - fromEmail: true, - fromName: true, - sentAt: true, - body: true, - snippet: true, + subject: true, + messageCount: true, + lastMessageAt: true, + contact: { + select: { id: true, firstName: true, lastName: true }, + }, + messages: { + orderBy: { sentAt: "desc" }, + take: options.messagesPerThread ?? 4, + select: { + direction: true, + fromEmail: true, + fromName: true, + sentAt: true, + body: true, + snippet: true, + }, + }, }, - }, - }, - }), - db.calendarEvent.findMany({ - where: - contactIds.length > 0 - ? { - OR: [ - { contactId: { in: contactIds } }, - { attendees: { some: { contactId: { in: contactIds } } } }, - { companyId: deal.company.id }, - ], - } - : { companyId: deal.company.id }, - orderBy: { startsAt: "desc" }, - take: 10, - select: { - title: true, - startsAt: true, - attendees: { select: { email: true, name: true } }, - }, - }), + }) + : Promise.resolve([]), + includeCalendar + ? db.calendarEvent.findMany({ + where: + contactIds.length > 0 + ? { + OR: [ + { contactId: { in: contactIds } }, + { + attendees: { some: { contactId: { in: contactIds } } }, + }, + { companyId: deal.company.id }, + ], + } + : { companyId: deal.company.id }, + orderBy: { startsAt: "desc" }, + take: 10, + select: { + title: true, + startsAt: true, + attendees: { select: { email: true, name: true } }, + }, + }) + : Promise.resolve([]), recentNotes({ dealId }), - db.emailMessage.findFirst({ - where: { direction: EmailDirection.INBOUND, thread: relatedThreads }, - orderBy: { sentAt: "desc" }, - select: { sentAt: true, fromEmail: true, fromName: true }, - }), + includeEmail + ? db.emailMessage.findFirst({ + where: { + direction: EmailDirection.INBOUND, + thread: relatedThreads, + }, + orderBy: { sentAt: "desc" }, + select: { sentAt: true, fromEmail: true, fromName: true }, + }) + : Promise.resolve(null), ]); const now = new Date(); @@ -469,9 +513,11 @@ export async function readDealHistory( : null, }, note: - contactIds.length > 0 - ? "Email and meetings are filed against people and companies, never against a deal. The correspondence here is with the people on this deal and with the rest of the account — read the subjects before treating any of it as being about this deal." - : "Nobody is attached to this deal, so the correspondence here is the whole account's. Attaching the people on it would make this answer sharper.", + includeEmail || includeCalendar + ? contactIds.length > 0 + ? "Connected account history is filed against people and companies, never against a deal. The history here belongs to the people on this deal and the rest of the account — read the details before treating any of it as being about this deal." + : "Nobody is attached to this deal, so the correspondence here is the whole account's. Attaching the people on it would make this answer sharper." + : "Connected email and calendar history are outside this agent version's approved data sources.", }; } diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts new file mode 100644 index 00000000..a13670ed --- /dev/null +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -0,0 +1,680 @@ +import { isDeepStrictEqual } from "node:util"; +import { db, type Prisma } from "@crm/db"; +import { readAgentModel } from "@crm/db/settings"; + +const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; +const CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly"; + +export type BuilderResource = { + kind: "integration" | "company" | "contact" | "deal"; + id: string; + label: string; +}; + +export type DraftTrigger = { + type: "MANUAL" | "SCHEDULE"; + name: string; + summary: string; + nextRunAt?: string | null; + intervalMinutes?: number | null; +}; + +export type DraftAction = + | { + type: "crm.activity.create"; + provider: "crm"; + summary: string; + activityTypes: ("NOTE" | "TASK")[]; + } + | { + type: "run.summary"; + provider: "crm"; + summary: string; + }; + +export type DraftAgentInput = { + name: string; + description: string; + instructions: string; + trigger: DraftTrigger; + recordScope: "SELECTED" | "WORKSPACE"; + resources: BuilderResource[]; + actions: DraftAction[]; + access: string[]; +}; + +export const BUILDER_ARTIFACT_PATHS = [ + "agent/README.md", + "agent/instructions.md", + "agent/manifest.json", +] as const; + +export type BuilderArtifactPath = (typeof BUILDER_ARTIFACT_PATHS)[number]; + +const ARTIFACT_LANGUAGES: Record = { + "agent/README.md": "markdown", + "agent/instructions.md": "markdown", + "agent/manifest.json": "json", +}; + +export async function writeBuilderArtifact( + conversationId: string, + userId: string, + path: BuilderArtifactPath, + content: string, +) { + assertSafeArtifact(content); + + return db.$transaction(async (tx) => { + const [conversation] = await tx.$queryRaw>` + SELECT id + FROM "agentConversation" + WHERE id = ${conversationId} + AND "userId" = ${userId} + AND kind = 'BUILDER' + FOR UPDATE + `; + if (!conversation) { + throw new Error("This builder conversation is unavailable."); + } + + const latest = await tx.agentBuilderArtifact.findFirst({ + where: { conversationId, path }, + orderBy: { revision: "desc" }, + select: { id: true, revision: true, content: true, status: true }, + }); + + if (latest?.content === content) { + return { saved: true as const, id: latest.id, revision: latest.revision }; + } + + const artifact = await tx.agentBuilderArtifact.create({ + data: { + conversationId, + path, + language: ARTIFACT_LANGUAGES[path], + content, + previousContent: latest?.content ?? null, + revision: (latest?.revision ?? 0) + 1, + status: "WRITING", + }, + select: { id: true, revision: true }, + }); + + return { saved: true as const, ...artifact }; + }); +} + +export async function builderContext(conversationId: string, userId: string) { + const conversation = await db.agentConversation.findFirst({ + where: { id: conversationId, userId, kind: "BUILDER" }, + select: { + id: true, + title: true, + agent: { + select: { + id: true, + name: true, + description: true, + status: true, + versions: { + orderBy: { number: "desc" }, + take: 1, + select: { + id: true, + number: true, + status: true, + manifest: true, + instructions: true, + }, + }, + }, + }, + submissions: { + orderBy: { createdAt: "asc" }, + select: { id: true, message: true, createdAt: true }, + }, + }, + }); + + if (!conversation) + throw new Error("This builder conversation is unavailable."); + + const resources = uniqueResources( + conversation.submissions.flatMap((submission) => + resourcesOf(submission.message), + ), + ); + + return { + conversation: { + id: conversation.id, + title: conversation.title, + }, + availableConnections: await connectionStatus(userId), + resources: await describeResources(resources), + existingDraft: conversation.agent, + now: new Date().toISOString(), + }; +} + +export async function saveBuilderDraft( + conversationId: string, + userId: string, + input: DraftAgentInput, +) { + const conversation = await db.agentConversation.findFirst({ + where: { id: conversationId, userId, kind: "BUILDER" }, + select: { + id: true, + submissions: { select: { message: true } }, + }, + }); + + if (!conversation) + throw new Error("This builder conversation is unavailable."); + + const taggedResources = uniqueResources( + conversation.submissions.flatMap((submission) => + resourcesOf(submission.message), + ), + ); + const validation = await validateDraft(userId, input, taggedResources); + if (!validation.valid) { + return { + saved: false as const, + issues: validation.issues, + availableConnections: validation.connections, + }; + } + + const model = await readAgentModel(db); + const now = new Date(); + const nextRunAt = scheduleDate(input.trigger, now); + const manifest = { + kind: "crm-team-agent", + name: input.name, + description: input.description, + trigger: { + type: input.trigger.type, + name: input.trigger.name, + summary: input.trigger.summary, + config: + input.trigger.type === "SCHEDULE" + ? { + intervalMinutes: input.trigger.intervalMinutes, + nextRunAt: nextRunAt?.toISOString(), + } + : {}, + }, + dataScope: { + mode: input.recordScope, + summary: scopeSummary(input.recordScope, input.resources), + resources: input.resources, + }, + actions: input.actions, + access: input.access, + }; + const sandboxPolicy = { + backend: "eve-default", + networkPolicy: "deny-all", + credentials: "app-runtime-only", + summary: "Isolated sandbox · deny-all network · bounded CRM tools", + }; + const files = artifactFiles(input, manifest); + for (const file of files) assertSafeArtifact(file.content); + + return db.$transaction(async (tx) => { + const [lockedConversation] = await tx.$queryRaw< + Array<{ id: string; agentId: string | null }> + >` + SELECT id, "agentId" + FROM "agentConversation" + WHERE id = ${conversationId} + AND "userId" = ${userId} + AND kind = 'BUILDER' + FOR UPDATE + `; + if (!lockedConversation) { + throw new Error("This builder conversation is unavailable."); + } + + let agentId = lockedConversation.agentId; + let created = false; + + if (!agentId) { + const agent = await tx.agentDefinition.create({ + data: { + name: input.name, + description: input.description, + createdById: userId, + }, + select: { id: true }, + }); + agentId = agent.id; + created = true; + + await tx.agentConversation.update({ + where: { id: conversationId }, + data: { agentId, title: input.name }, + }); + } else { + const [agent] = await tx.$queryRaw< + Array<{ + status: "DRAFT" | "LIVE" | "PAUSED" | "ARCHIVED" | "DELETED"; + }> + >` + SELECT status + FROM "agentDefinition" + WHERE id = ${agentId} + FOR UPDATE + `; + if (!agent || agent.status === "DELETED") { + throw new Error("This agent is unavailable."); + } + if (agent.status === "DRAFT") { + await tx.agentDefinition.update({ + where: { id: agentId }, + data: { name: input.name, description: input.description }, + }); + } + } + + const latest = await tx.agentVersion.findFirst({ + where: { agentId }, + orderBy: { number: "desc" }, + select: { + id: true, + number: true, + status: true, + instructions: true, + manifest: true, + modelId: true, + }, + }); + if ( + latest?.status === "READY" && + latest.instructions === input.instructions && + latest.modelId === model.id && + isDeepStrictEqual(latest.manifest, manifest) + ) { + await persistArtifactSnapshots(tx, conversationId, latest.id, files); + return { + saved: true as const, + agentId, + versionId: latest.id, + versionNumber: latest.number, + status: latest.status, + }; + } + + const number = (latest?.number ?? 0) + 1; + const version = await tx.agentVersion.create({ + data: { + agentId, + number, + status: "READY", + instructions: input.instructions, + manifest: manifest as Prisma.InputJsonValue, + modelId: model.id, + sandboxPolicy, + validation: { + status: "passed", + checkedAt: now.toISOString(), + capabilities: validation.capabilities, + }, + sourceConversationId: conversationId, + createdById: userId, + }, + select: { id: true, number: true, status: true }, + }); + + await persistArtifactSnapshots(tx, conversationId, version.id, files); + + await tx.agentTrigger.create({ + data: { + agentId, + versionId: version.id, + type: input.trigger.type, + name: input.trigger.name, + config: manifest.trigger.config as Prisma.InputJsonValue, + createdById: userId, + nextRunAt, + }, + }); + + if (created) { + await tx.agentAuditEvent.create({ + data: { + agentId, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.created", + summary: "Created a draft agent from a private builder chat", + requestId: `builder:${conversationId}`, + }, + }); + } + + await tx.agentAuditEvent.create({ + data: { + agentId, + versionId: version.id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "version.created", + summary: `Prepared version ${number} for review`, + requestId: version.id, + after: { status: "READY", validation: "passed" }, + }, + }); + + return { + saved: true as const, + agentId, + versionId: version.id, + versionNumber: version.number, + status: version.status, + }; + }); +} + +async function persistArtifactSnapshots( + tx: Prisma.TransactionClient, + conversationId: string, + versionId: string, + files: ReturnType, +) { + for (const file of files) { + const latestArtifact = await tx.agentBuilderArtifact.findFirst({ + where: { conversationId, path: file.path }, + orderBy: { revision: "desc" }, + select: { + id: true, + versionId: true, + revision: true, + content: true, + status: true, + }, + }); + if ( + latestArtifact?.content === file.content && + latestArtifact.versionId === versionId && + latestArtifact.status === "READY" + ) { + continue; + } + if ( + latestArtifact?.content === file.content && + latestArtifact.status === "WRITING" && + latestArtifact.versionId === null + ) { + await tx.agentBuilderArtifact.update({ + where: { id: latestArtifact.id }, + data: { versionId, status: "READY" }, + }); + continue; + } + + await tx.agentBuilderArtifact.create({ + data: { + conversationId, + versionId, + path: file.path, + language: file.language, + content: file.content, + previousContent: latestArtifact?.content ?? null, + revision: (latestArtifact?.revision ?? 0) + 1, + status: "READY", + }, + }); + } +} + +async function validateDraft( + userId: string, + input: DraftAgentInput, + taggedResources: BuilderResource[], +) { + const connections = await connectionStatus(userId); + const issues: string[] = []; + const capabilities = new Set(["crm.read"]); + const resourceKeys = new Set(); + const recordResources = input.resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (input.recordScope === "SELECTED" && recordResources.length === 0) { + issues.push("Selected CRM scope needs at least one tagged record."); + } + if (input.recordScope === "WORKSPACE" && recordResources.length > 0) { + issues.push("Workspace CRM scope cannot also list selected records."); + } + const taggedRecordKeys = new Set( + taggedResources + .filter((resource) => resource.kind !== "integration") + .map((resource) => `${resource.kind}:${resource.id}`), + ); + for (const resource of recordResources) { + if (!taggedRecordKeys.has(`${resource.kind}:${resource.id}`)) { + issues.push(`${resource.label} was not tagged in this builder chat.`); + } + } + + for (const resource of input.resources) { + const key = `${resource.kind}:${resource.id}`; + if (resourceKeys.has(key)) { + issues.push(`${resource.label} is listed more than once.`); + continue; + } + resourceKeys.add(key); + + if (resource.kind !== "integration") continue; + if (resource.id === "google:gmail" && !connections.gmail) { + issues.push("Gmail is not connected for the chat owner."); + } + if (resource.id === "google:calendar" && !connections.calendar) { + issues.push("Google Calendar is not connected for the chat owner."); + } + if (!["google:gmail", "google:calendar"].includes(resource.id)) { + issues.push(`${resource.label} is not an available integration.`); + continue; + } + capabilities.add(`${resource.id}.read`); + } + + for (const action of input.actions) { + capabilities.add(action.type); + if (action.type !== "crm.activity.create") continue; + if (new Set(action.activityTypes).size !== action.activityTypes.length) { + issues.push("CRM activity permissions must not repeat an activity type."); + } + } + + const missingRecords = await missingResourceIds(input.resources); + issues.push( + ...missingRecords.map( + (resource) => `${resource.label} is no longer in the CRM.`, + ), + ); + + if (input.trigger.type === "SCHEDULE") { + const next = Date.parse(input.trigger.nextRunAt ?? ""); + if (!Number.isFinite(next) || next <= Date.now()) { + issues.push("A scheduled agent needs a future next run time."); + } + if ( + !input.trigger.intervalMinutes || + input.trigger.intervalMinutes < 1 || + input.trigger.intervalMinutes > 525_600 + ) { + issues.push( + "A scheduled agent needs a recurrence from 1 minute to 1 year.", + ); + } + } + + return { + valid: issues.length === 0, + issues, + connections, + capabilities: [...capabilities], + }; +} + +async function connectionStatus(userId: string) { + const accounts = await db.account.findMany({ + where: { userId, providerId: "google" }, + select: { scope: true }, + }); + const scopes = new Set( + accounts.flatMap((account) => (account.scope ?? "").split(/[,\s]+/)), + ); + + return { + gmail: scopes.has(GMAIL_SCOPE), + calendar: scopes.has(CALENDAR_SCOPE), + crm: true, + }; +} + +async function describeResources(resources: BuilderResource[]) { + return Promise.all( + resources.map(async (resource) => { + if (resource.kind === "company") { + const row = await db.company.findUnique({ + where: { id: resource.id }, + select: { id: true, name: true, domain: true, industry: true }, + }); + return { ...resource, record: row }; + } + if (resource.kind === "contact") { + const row = await db.contact.findUnique({ + where: { id: resource.id }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + title: true, + company: { select: { id: true, name: true } }, + }, + }); + return { ...resource, record: row }; + } + if (resource.kind === "deal") { + const row = await db.deal.findUnique({ + where: { id: resource.id }, + select: { + id: true, + name: true, + stage: true, + amount: true, + currency: true, + company: { select: { id: true, name: true } }, + }, + }); + return { + ...resource, + record: row + ? { + ...row, + amount: row.amount === null ? null : Number(row.amount), + } + : null, + }; + } + return { ...resource, record: null }; + }), + ); +} + +async function missingResourceIds(resources: BuilderResource[]) { + const described = await describeResources( + resources.filter((resource) => resource.kind !== "integration"), + ); + return described.filter((resource) => !resource.record); +} + +function resourcesOf(value: unknown): BuilderResource[] { + if (!value || typeof value !== "object" || !("resources" in value)) return []; + const resources = (value as { resources?: unknown }).resources; + if (!Array.isArray(resources)) return []; + + return resources.flatMap((resource) => { + if (!resource || typeof resource !== "object") return []; + const row = resource as Record; + if ( + !["integration", "company", "contact", "deal"].includes( + String(row.kind), + ) || + typeof row.id !== "string" || + typeof row.label !== "string" + ) { + return []; + } + return [resource as BuilderResource]; + }); +} + +function uniqueResources(resources: BuilderResource[]): BuilderResource[] { + return [ + ...new Map( + resources.map((resource) => [ + `${resource.kind}:${resource.id}`, + resource, + ]), + ).values(), + ]; +} + +function scheduleDate(trigger: DraftTrigger, now: Date): Date | null { + if (trigger.type !== "SCHEDULE") return null; + const parsed = new Date(trigger.nextRunAt ?? ""); + return parsed > now ? parsed : null; +} + +function artifactFiles(input: DraftAgentInput, manifest: object) { + return [ + { + path: "agent/README.md" as const, + language: ARTIFACT_LANGUAGES["agent/README.md"], + content: `# ${input.name}\n\n${input.description}\n\n## Trigger\n\n${input.trigger.summary}\n\n## Access\n\n${input.access.map((item) => `- ${item}`).join("\n") || "- CRM data in the approved scope"}\n`, + }, + { + path: "agent/instructions.md" as const, + language: ARTIFACT_LANGUAGES["agent/instructions.md"], + content: `${input.instructions.trim()}\n`, + }, + { + path: "agent/manifest.json" as const, + language: ARTIFACT_LANGUAGES["agent/manifest.json"], + content: `${JSON.stringify(manifest, null, 2)}\n`, + }, + ]; +} + +function assertSafeArtifact(content: string): void { + const secretPatterns = [ + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i, + /\b(?:api[_-]?key|password|secret|access[_-]?token)\b\s*[:=]\s*["']?[a-z0-9_./+=-]{12,}/i, + /\b(?:sk|pk)_(?:live|test)_[a-z0-9]{16,}/i, + ]; + + if (secretPatterns.some((pattern) => pattern.test(content))) { + throw new Error("Agent files cannot contain credentials or secret values."); + } +} + +function scopeSummary( + recordScope: DraftAgentInput["recordScope"], + resources: BuilderResource[], +): string { + const records = resources.filter( + (resource) => resource.kind !== "integration", + ); + if (recordScope === "WORKSPACE") return "Workspace CRM records"; + return records.map((resource) => resource.label).join(" · "); +} diff --git a/apps/agent/agent/lib/context-dev.ts b/apps/agent/agent/lib/context-dev.ts index 30341d47..6f093c43 100644 --- a/apps/agent/agent/lib/context-dev.ts +++ b/apps/agent/agent/lib/context-dev.ts @@ -99,29 +99,22 @@ export async function verifyKey(key: string): Promise { } } -/** - * 401 is the only answer that means *this key is wrong*. Everything else that - * came back from Context.dev — a 422 refusing the probe address, a 403 about - * the plan, a 429, a 500 — was served *after* the key authenticated, so the - * key is good and only the probe was refused. Anything that never reached them - * says nothing about the key at all. - */ export function classifyKey(error: unknown): KeyCheck { if (!(error instanceof APIError)) { return { outcome: "unknown", reason: describe(error) }; } - if (error.status === 401) { + if (error.status === undefined) { + return { outcome: "unknown", reason: describe(error) }; + } + + if (error.status === 401 && !recognisedKeyFailure(error)) { return { outcome: "invalid", reason: "Context did not recognise that API key.", }; } - if (error.status === undefined) { - return { outcome: "unknown", reason: describe(error) }; - } - return { outcome: "valid" }; } @@ -281,6 +274,19 @@ function errorCode(error: APIError): string | undefined { return typeof body?.error_code === "string" ? body.error_code : undefined; } +function recognisedKeyFailure(error: APIError): boolean { + const body = error.error as + | { error_code?: unknown; message?: unknown } + | undefined; + const detail = [body?.error_code, body?.message, error.message] + .filter((value): value is string => typeof value === "string") + .join(" "); + + return /(usage|credit|quota|allowance|billing|rate.?limit|limit.?exceeded|insufficient.?permission)/i.test( + detail, + ); +} + function describe(error: unknown): string { if (error instanceof APIError) { return `${error.status ?? "?"} ${errorCode(error) ?? error.message}`; diff --git a/apps/agent/agent/lib/conversation-title.ts b/apps/agent/agent/lib/conversation-title.ts new file mode 100644 index 00000000..5eb2efbb --- /dev/null +++ b/apps/agent/agent/lib/conversation-title.ts @@ -0,0 +1,40 @@ +import { db } from "@crm/db"; + +export const BUILDER_CONVERSATION_TITLE_MAX_LENGTH = 60; + +export async function setBuilderConversationTitle( + conversationId: string, + userId: string, + title: string, +) { + const normalized = title + .replace(/\s+/g, " ") + .trim() + .replace(/^["'“”‘’]+|["'“”‘’]+$/g, "") + .slice(0, BUILDER_CONVERSATION_TITLE_MAX_LENGTH) + .trim(); + + if (!normalized) throw new Error("A chat title cannot be empty."); + + const updated = await db.agentConversation.updateMany({ + where: { + id: conversationId, + userId, + kind: "BUILDER", + title: null, + }, + data: { title: normalized }, + }); + + if (updated.count === 0) { + const conversation = await db.agentConversation.findFirst({ + where: { id: conversationId, userId, kind: "BUILDER" }, + select: { title: true }, + }); + if (!conversation) + throw new Error("This builder conversation is unavailable."); + return { saved: false as const, title: conversation.title }; + } + + return { saved: true as const, title: normalized }; +} diff --git a/apps/agent/agent/lib/crm.ts b/apps/agent/agent/lib/crm.ts index 7301cc0b..91599187 100644 --- a/apps/agent/agent/lib/crm.ts +++ b/apps/agent/agent/lib/crm.ts @@ -158,7 +158,12 @@ export type CrmHistory = { export async function readCrmHistory( contactId: string, - options: { threads?: number; messagesPerThread?: number } = {}, + options: { + threads?: number; + messagesPerThread?: number; + includeEmail?: boolean; + includeCalendar?: boolean; + } = {}, ): Promise { const contact = await db.contact.findUnique({ where: { id: contactId }, @@ -192,49 +197,55 @@ export async function readCrmHistory( }); if (!contact) return null; + const includeEmail = options.includeEmail ?? true; + const includeCalendar = options.includeCalendar ?? true; const [threads, meetings, colleagues] = await Promise.all([ - db.emailThread.findMany({ - where: { contactId }, - orderBy: { lastMessageAt: "desc" }, - take: options.threads ?? 5, - select: { - subject: true, - messageCount: true, - lastMessageAt: true, - messages: { - orderBy: { sentAt: "desc" }, - take: options.messagesPerThread ?? 6, + includeEmail + ? db.emailThread.findMany({ + where: { contactId }, + orderBy: { lastMessageAt: "desc" }, + take: options.threads ?? 5, select: { - direction: true, - fromEmail: true, - fromName: true, - sentAt: true, - body: true, - snippet: true, + subject: true, + messageCount: true, + lastMessageAt: true, + messages: { + orderBy: { sentAt: "desc" }, + take: options.messagesPerThread ?? 6, + select: { + direction: true, + fromEmail: true, + fromName: true, + sentAt: true, + body: true, + snippet: true, + }, + }, }, - }, - }, - }), - db.calendarEvent.findMany({ - where: { - OR: [{ contactId }, { attendees: { some: { contactId } } }], - }, - orderBy: { startsAt: "desc" }, - take: 10, - select: { - title: true, - startsAt: true, - attendees: { + }) + : Promise.resolve([]), + includeCalendar + ? db.calendarEvent.findMany({ + where: { + OR: [{ contactId }, { attendees: { some: { contactId } } }], + }, + orderBy: { startsAt: "desc" }, + take: 10, select: { - email: true, - name: true, - contactId: true, - responseStatus: true, + title: true, + startsAt: true, + attendees: { + select: { + email: true, + name: true, + contactId: true, + responseStatus: true, + }, + }, }, - }, - }, - }), + }) + : Promise.resolve([]), contact.companyId ? db.contact.findMany({ where: { companyId: contact.companyId, id: { not: contactId } }, diff --git a/apps/agent/agent/lib/custom-agent-dispatch.ts b/apps/agent/agent/lib/custom-agent-dispatch.ts new file mode 100644 index 00000000..dfde6535 --- /dev/null +++ b/apps/agent/agent/lib/custom-agent-dispatch.ts @@ -0,0 +1,624 @@ +import { db, type Prisma } from "@crm/db"; +import type { SendFn } from "eve/channels"; +import { lockAgentRun, runTerminalEventId } from "./run-state"; + +const BUILDER_BATCH = 20; +const RUN_BATCH = 20; +const MAX_BUILDER_ATTEMPTS = 3; +const BUILDER_LEASE_MS = 5 * 60_000; +const RUN_DELIVERY_LEASE_MS = 5 * 60_000; + +export async function pendingBuilderSubmissionIds(): Promise { + await recoverBuilderSubmissions(); + const rows = await db.agentConversationSubmission.findMany({ + where: { + status: "PENDING", + conversation: { + kind: "BUILDER", + OR: [{ sessionId: null }, { continuationToken: { not: null } }], + }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: BUILDER_BATCH * 3, + select: { id: true, conversationId: true }, + }); + + const seen = new Set(); + return rows + .flatMap((row) => { + if (seen.has(row.conversationId)) return []; + seen.add(row.conversationId); + return [row.id]; + }) + .slice(0, BUILDER_BATCH); +} + +export async function drainBuilder(send: SendFn): Promise { + const ids = await pendingBuilderSubmissionIds(); + await Promise.all(ids.map((id) => dispatchBuilderSubmission(id, send))); + return ids.length; +} + +export async function dispatchBuilderSubmission( + submissionId: string, + send: SendFn, +) { + const submission = await db.$transaction(async (tx) => { + const seed = await tx.agentConversationSubmission.findUnique({ + where: { id: submissionId }, + select: { conversationId: true }, + }); + if (!seed) throw new Error("Builder submission is unavailable."); + + const conversation = await lockBuilderConversation(tx, seed.conversationId); + if (conversation?.kind !== "BUILDER") { + throw new Error("Builder submission is unavailable."); + } + if (conversation.sessionId && !conversation.continuationToken) { + throw new Error("Builder conversation is still processing a message."); + } + + const [active, firstPending] = await Promise.all([ + tx.agentConversationSubmission.findFirst({ + where: { conversationId: conversation.id, status: "SENDING" }, + select: { id: true }, + }), + tx.agentConversationSubmission.findFirst({ + where: { conversationId: conversation.id, status: "PENDING" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { id: true }, + }), + ]); + if (active || firstPending?.id !== submissionId) { + throw new Error( + "Builder submission was already claimed or is out of order.", + ); + } + + await tx.agentConversationSubmission.update({ + where: { id: submissionId }, + data: { + status: "SENDING", + attemptCount: { increment: 1 }, + sentAt: new Date(), + errorCode: null, + errorMessage: null, + }, + }); + await tx.agentConversation.update({ + where: { id: conversation.id }, + data: { continuationToken: null }, + }); + + return tx.agentConversationSubmission.findUniqueOrThrow({ + where: { id: submissionId }, + select: { + id: true, + commandType: true, + message: true, + attemptCount: true, + attachments: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + name: true, + mediaType: true, + content: true, + }, + }, + conversation: { + select: { + id: true, + title: true, + userId: true, + kind: true, + }, + }, + }, + }); + }); + const conversationId = submission.conversation.id; + + try { + const session = await send( + builderDeliveryMessage( + submission.id, + submission.message, + submission.attachments, + ), + { + auth: { + authenticator: "crm-builder", + principalType: "user", + principalId: submission.conversation.userId, + attributes: { + purpose: "builder", + commandType: submission.commandType, + needsTitle: submission.conversation.title ? "false" : "true", + conversationId, + userId: submission.conversation.userId, + submissionId: submission.id, + }, + }, + continuationToken: builderToken(conversationId), + title: submission.conversation.title ?? "Agent builder", + }, + ); + + await db.$transaction(async (tx) => { + const conversation = await lockBuilderConversation(tx, conversationId); + if (!conversation) return; + await tx.agentConversationSubmission.update({ + where: { id: submission.id }, + data: { status: "ACCEPTED", acceptedAt: new Date() }, + }); + await tx.agentConversation.update({ + where: { id: conversationId }, + data: { sessionId: session.id }, + }); + }); + + return session; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const retry = submission.attemptCount < MAX_BUILDER_ATTEMPTS; + await db.$transaction(async (tx) => { + const conversation = await lockBuilderConversation(tx, conversationId); + if (!conversation) return; + await tx.agentConversationSubmission.update({ + where: { id: submission.id }, + data: { + status: retry ? "PENDING" : "FAILED", + errorCode: "DELIVERY_FAILED", + errorMessage: message, + }, + }); + await tx.agentConversation.update({ + where: { id: conversationId }, + data: { continuationToken: builderToken(conversationId) }, + }); + }); + throw error; + } +} + +export async function queueDueAgentRuns(now = new Date()): Promise { + const triggers = await db.agentTrigger.findMany({ + where: { + enabled: true, + type: "SCHEDULE", + nextRunAt: { lte: now }, + agent: { status: "LIVE" }, + }, + orderBy: [{ nextRunAt: "asc" }, { id: "asc" }], + take: RUN_BATCH, + select: { + id: true, + agentId: true, + versionId: true, + nextRunAt: true, + config: true, + }, + }); + + let queued = 0; + for (const trigger of triggers) { + if (!trigger.nextRunAt) continue; + const scheduledAt = trigger.nextRunAt; + const intervalMinutes = intervalOf(trigger.config); + const nextRunAt = advance(scheduledAt, intervalMinutes, now); + const idempotencyKey = `${trigger.id}:${scheduledAt.toISOString()}`; + const claimed = await db.$transaction(async (tx) => { + const [agent] = await tx.$queryRaw>` + SELECT id, status + FROM "agentDefinition" + WHERE id = ${trigger.agentId} + FOR UPDATE + `; + if (agent?.status !== "LIVE") return false; + + const updated = await tx.agentTrigger.updateMany({ + where: { + id: trigger.id, + nextRunAt: scheduledAt, + enabled: true, + }, + data: { nextRunAt, lastRunAt: scheduledAt }, + }); + if (updated.count === 0) return false; + + await tx.agentRun.upsert({ + where: { idempotencyKey }, + create: { + agentId: trigger.agentId, + versionId: trigger.versionId, + triggerId: trigger.id, + triggerType: "SCHEDULE", + idempotencyKey, + correlationId: crypto.randomUUID(), + input: { scheduledFor: scheduledAt.toISOString() }, + events: { + create: { sequence: 0, type: "run.queued", data: {} }, + }, + }, + update: {}, + }); + return true; + }); + if (claimed) queued += 1; + } + + return queued; +} + +export async function pendingAgentRunIds(): Promise { + await recoverAgentRuns(); + const rows = await db.agentRun.findMany({ + where: { status: "QUEUED", agent: { status: "LIVE" } }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: RUN_BATCH, + select: { id: true }, + }); + return rows.map((row) => row.id); +} + +export async function drainAgentRuns(send: SendFn): Promise { + await queueDueAgentRuns(); + const ids = await pendingAgentRunIds(); + await Promise.all(ids.map((id) => dispatchAgentRun(id, send))); + return ids.length; +} + +export async function dispatchAgentRun(runId: string, send: SendFn) { + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { + id: true, + status: true, + agentId: true, + versionId: true, + initiatedById: true, + agent: { + select: { name: true, createdById: true, status: true }, + }, + version: { select: { modelId: true } }, + }, + }); + if (run?.status !== "QUEUED" || run.agent.status !== "LIVE") { + throw new Error("Agent run was already claimed or is not live."); + } + + const claimed = await db.$transaction(async (tx) => { + const [agent] = await tx.$queryRaw>` + SELECT id, status + FROM "agentDefinition" + WHERE id = ${run.agentId} + FOR UPDATE + `; + if (agent?.status !== "LIVE") return false; + + const updated = await tx.agentRun.updateMany({ + where: { id: runId, status: "QUEUED" }, + data: { + status: "RUNNING", + startedAt: new Date(), + modelId: run.version.modelId, + }, + }); + return updated.count === 1; + }); + if (!claimed) + throw new Error("Agent run was already claimed or is not live."); + + const principalId = run.initiatedById ?? run.agent.createdById; + try { + const session = await send(`Execute deployed agent run ${run.id}.`, { + auth: { + authenticator: run.initiatedById ? "crm-user" : "crm-schedule", + principalType: run.initiatedById ? "user" : "runtime", + principalId, + attributes: { + purpose: "team-agent", + runId: run.id, + agentId: run.agentId, + versionId: run.versionId, + userId: principalId, + }, + }, + continuationToken: runToken(run.id), + title: `${run.agent.name} run`, + mode: "task", + }); + + await db.agentRun.updateMany({ + where: { id: run.id, status: "RUNNING" }, + data: { sessionId: session.id }, + }); + return session; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await failRun(run.id, "DELIVERY_FAILED", message); + throw error; + } +} + +export async function failRun(runId: string, code: string, message: string) { + return db.$transaction(async (tx) => { + const run = await lockAgentRun(tx, runId); + if (run.status === "FAILED") { + return { id: run.id, status: "FAILED" as const }; + } + if (run.status === "SUCCEEDED" || run.status === "CANCELLED") { + return { id: run.id, status: run.status }; + } + + const sequence = run.nextEventSequence + 1; + const finishedAt = new Date(); + await tx.agentRun.update({ + where: { id: runId }, + data: { + status: "FAILED", + errorCode: code, + errorMessage: message, + finishedAt, + nextEventSequence: sequence, + }, + }); + await tx.agentRunEvent.create({ + data: { + id: runTerminalEventId(run.id, "failed"), + runId: run.id, + sequence, + type: "run.failed", + data: { code, message }, + emittedAt: finishedAt, + }, + }); + await tx.agentAuditEvent.upsert({ + where: { + agentId_type_requestId: { + agentId: run.agentId, + type: "run.failed", + requestId: run.id, + }, + }, + create: { + agentId: run.agentId, + versionId: run.versionId, + actorType: "AGENT", + actorId: run.id, + type: "run.failed", + summary: message, + requestId: run.id, + }, + update: {}, + }); + + return { id: run.id, status: "FAILED" as const }; + }); +} + +export function builderToken(conversationId: string): string { + return `builder:${conversationId}`; +} + +export function builderIdFromToken(token: string | undefined): string | null { + return idFromToken(token, "builder:"); +} + +export function runToken(runId: string): string { + return `run:${runId}`; +} + +export function runIdFromToken(token: string | undefined): string | null { + return idFromToken(token, "run:"); +} + +async function recoverBuilderSubmissions() { + const stale = new Date(Date.now() - BUILDER_LEASE_MS); + const rows = await db.agentConversationSubmission.findMany({ + where: { + status: "SENDING", + sentAt: { lt: stale }, + }, + orderBy: [{ sentAt: "asc" }, { id: "asc" }], + take: BUILDER_BATCH * 3, + select: { id: true, conversationId: true, attemptCount: true }, + }); + + for (const row of rows) { + await db.$transaction(async (tx) => { + const conversation = await lockBuilderConversation( + tx, + row.conversationId, + ); + if (conversation?.kind !== "BUILDER") return; + const claimed = await tx.agentConversationSubmission.updateMany({ + where: { id: row.id, status: "SENDING", sentAt: { lt: stale } }, + data: + row.attemptCount < MAX_BUILDER_ATTEMPTS + ? { status: "PENDING" } + : { + status: "FAILED", + errorCode: "DELIVERY_EXHAUSTED", + errorMessage: + "The builder could not accept this message after three attempts.", + }, + }); + if (claimed.count === 0) return; + + await tx.agentConversation.updateMany({ + where: { id: row.conversationId, kind: "BUILDER" }, + data: { continuationToken: builderToken(row.conversationId) }, + }); + }); + } +} + +type LockedBuilderConversation = { + id: string; + kind: string; + sessionId: string | null; + continuationToken: string | null; +}; + +async function lockBuilderConversation( + tx: Prisma.TransactionClient, + conversationId: string, +): Promise { + const [conversation] = await tx.$queryRaw` + SELECT id, kind, "sessionId", "continuationToken" + FROM "agentConversation" + WHERE id = ${conversationId} + FOR UPDATE + `; + return conversation ?? null; +} + +async function recoverAgentRuns() { + const stale = new Date(Date.now() - RUN_DELIVERY_LEASE_MS); + const rows = await db.agentRun.findMany({ + where: { + status: "RUNNING", + sessionId: null, + startedAt: { lt: stale }, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: RUN_BATCH * 3, + select: { id: true, agentId: true }, + }); + + for (const row of rows) { + await db.$transaction(async (tx) => { + const [agent] = await tx.$queryRaw>` + SELECT status + FROM "agentDefinition" + WHERE id = ${row.agentId} + FOR UPDATE + `; + const run = await lockAgentRun(tx, row.id); + if ( + run.status !== "RUNNING" || + run.sessionId !== null || + !run.startedAt || + run.startedAt >= stale + ) { + return; + } + + const sequence = run.nextEventSequence + 1; + const cancelled = agent?.status !== "LIVE" && agent?.status !== "PAUSED"; + await tx.agentRun.update({ + where: { id: run.id }, + data: cancelled + ? { + status: "CANCELLED", + errorCode: "AGENT_UNAVAILABLE", + errorMessage: + "The agent was unavailable when delivery recovery ran.", + finishedAt: new Date(), + nextEventSequence: sequence, + } + : { + status: "QUEUED", + startedAt: null, + errorCode: null, + errorMessage: null, + finishedAt: null, + nextEventSequence: sequence, + }, + }); + await tx.agentRunEvent.create({ + data: { + id: `run-delivery-${cancelled ? "cancelled" : "recovered"}:${run.id}:${run.startedAt.toISOString()}`, + runId: run.id, + sequence, + type: cancelled ? "run.cancelled" : "run.delivery_recovered", + data: cancelled ? { reason: "agent.unavailable" } : {}, + }, + }); + }); + } +} + +export function builderDeliveryMessage( + submissionId: string, + value: unknown, + attachments: readonly BuilderDeliveryAttachment[] = [], +): Parameters[0] { + const message = recordOf(value); + const inputResponse = recordOf(message.inputResponse); + const response = + typeof inputResponse.requestId === "string" && + typeof inputResponse.answer === "string" + ? inputResponse.answer.trim() + : ""; + if (response) return response; + + const text = typeof message.text === "string" ? message.text : ""; + const resources = Array.isArray(message.resources) ? message.resources : []; + const context = [ + `Submission id: ${submissionId}`, + resources.length > 0 + ? `Tagged resources: ${resources.map(resourceLabel).filter(Boolean).join(", ")}` + : null, + ] + .filter(Boolean) + .join("\n"); + const parts: Array> = [ + { type: "text", text: `${context}\n\n${text}` }, + ]; + + for (const attachment of attachments) { + parts.push({ + type: "file", + data: attachment.content, + mediaType: attachment.mediaType, + filename: attachment.name, + }); + } + + return parts as Parameters[0]; +} + +type BuilderDeliveryAttachment = { + name: string; + mediaType: string; + content: Uint8Array; +}; + +function resourceLabel(value: unknown): string | null { + const row = recordOf(value); + return typeof row.label === "string" ? row.label : null; +} + +function intervalOf(value: unknown): number { + const interval = recordOf(value).intervalMinutes; + return typeof interval === "number" && + Number.isFinite(interval) && + interval >= 1 + ? Math.min(interval, 525_600) + : 1440; +} + +function advance(from: Date, intervalMinutes: number, now: Date): Date { + const intervalMs = intervalMinutes * 60_000; + const missed = Math.max( + 1, + Math.floor((now.getTime() - from.getTime()) / intervalMs) + 1, + ); + return new Date(from.getTime() + missed * intervalMs); +} + +function idFromToken(token: string | undefined, marker: string): string | null { + if (!token) return null; + const index = token.lastIndexOf(marker); + if (index === -1) return null; + const id = token.slice(index + marker.length); + return id || null; +} + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} diff --git a/apps/agent/agent/lib/lookup.ts b/apps/agent/agent/lib/lookup.ts index ecd5fbe9..b13e115e 100644 --- a/apps/agent/agent/lib/lookup.ts +++ b/apps/agent/agent/lib/lookup.ts @@ -1,6 +1,15 @@ -import { db } from "@crm/db"; +import { DealStage, db } from "@crm/db"; import { domainOf, normalise } from "./names"; +const OPEN_DEAL_STAGES = [ + DealStage.DEMO_BOOKED, + DealStage.QUALIFIED_TO_BUY, + DealStage.DECISION_MAKER_BOUGHT_IN, + DealStage.CONTRACT_SENT, +]; + +const LOST_DEAL_STAGES = [DealStage.CLOSED_LOST, DealStage.UNQUALIFIED_TO_BUY]; + export type RecordKind = "contact" | "company" | "deal"; export type ContactHit = { @@ -43,6 +52,117 @@ export type SearchResult = { total: number; }; +export type DealListStatus = "open" | "won" | "lost" | "all"; + +export type DealListOptions = { + status?: DealListStatus; + inactiveForDays?: number; + companyId?: string; + ownerId?: string; + limit?: number; + cursor?: string; + now?: Date; +}; + +export async function listDeals(options: DealListOptions = {}) { + const status = options.status ?? "open"; + const limit = Math.min(Math.max(options.limit ?? 50, 1), 100); + const now = options.now ?? new Date(); + const cutoff = + options.inactiveForDays === undefined + ? null + : new Date( + now.getTime() - Math.max(options.inactiveForDays, 0) * 86_400_000, + ); + const stages = + status === "open" + ? OPEN_DEAL_STAGES + : status === "won" + ? [DealStage.CLOSED_WON] + : status === "lost" + ? LOST_DEAL_STAGES + : null; + + const rows = await db.deal.findMany({ + where: { + ...(stages ? { stage: { in: stages } } : {}), + ...(options.companyId ? { companyId: options.companyId } : {}), + ...(options.ownerId ? { ownerId: options.ownerId } : {}), + ...(cutoff + ? { + OR: [ + { lastActivityAt: { lte: cutoff } }, + { lastActivityAt: null, createdAt: { lte: cutoff } }, + ], + } + : {}), + }, + orderBy: [ + { lastActivityAt: { sort: "asc", nulls: "first" } }, + { createdAt: "asc" }, + { id: "asc" }, + ], + ...(options.cursor ? { cursor: { id: options.cursor }, skip: 1 } : {}), + take: limit + 1, + select: { + id: true, + name: true, + stage: true, + amount: true, + currency: true, + createdAt: true, + lastActivityAt: true, + expectedCloseDate: true, + company: { + select: { + id: true, + name: true, + domain: true, + iconUrl: true, + iconDarkUrl: true, + iconTone: true, + logoUrl: true, + }, + }, + owner: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + const hasMore = rows.length > limit; + const page = rows.slice(0, limit); + + return { + criteria: { + status, + inactiveForDays: options.inactiveForDays ?? null, + companyId: options.companyId ?? null, + ownerId: options.ownerId ?? null, + }, + asOf: now.toISOString(), + deals: page.map((deal) => { + const activityDate = deal.lastActivityAt ?? deal.createdAt; + return { + id: deal.id, + name: deal.name, + stage: deal.stage, + amount: deal.amount === null ? null : Number(deal.amount), + currency: deal.currency, + company: deal.company, + owner: deal.owner, + createdAt: deal.createdAt.toISOString(), + lastActivityAt: deal.lastActivityAt?.toISOString() ?? null, + daysSinceLastActivity: Math.max( + 0, + Math.floor((now.getTime() - activityDate.getTime()) / 86_400_000), + ), + neverActive: deal.lastActivityAt === null, + expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null, + }; + }), + hasMore, + nextCursor: hasMore ? page.at(-1)?.id : null, + }; +} + export async function searchCrm( query: string, options: { kinds?: RecordKind[]; limit?: number } = {}, diff --git a/apps/agent/agent/lib/research-instructions.ts b/apps/agent/agent/lib/research-instructions.ts new file mode 100644 index 00000000..b29b5781 --- /dev/null +++ b/apps/agent/agent/lib/research-instructions.ts @@ -0,0 +1,26 @@ +export const RESEARCH_INSTRUCTIONS = `# CRM research agent + +Work out who the people in the CRM are, what the companies are, and where deals +stand so a rep opens a record already knowing what they are dealing with. + +Never write a fact you have not read from a source. A confidently wrong fact is +worse than a missing one. If you cannot confirm something, leave it missing. +Report evidence through the evidence tools instead of asserting confidence. + +Read the record you were opened on before doing anything else. Use +read_crm_history for a contact, read_company_history for a company, and +read_deal_history for a deal. These CRM reads are free, authoritative, and join +to related contacts, companies, and deals. Use search_crm when a request names a +record without an id. Never ask a rep to find an id the CRM can resolve. + +Look outside the CRM only after reading internal history. Prefer LinkedIn for +identity and the open web for context. Search results point to sources but are +not themselves evidence. When an install lacks a vendor capability, continue +with CRM evidence instead of treating that absence as a failure. + +Only vendor calls spend the session research budget. When it is gone, write up +what you have and stop, or schedule a recheck when another look is justified. + +Load identity-matching before deciding whether a candidate is the same person, +evidence before recording facts, writing-a-brief before a background brief, and +data-boundaries before moving data outside the CRM.`; diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts new file mode 100644 index 00000000..6a86e447 --- /dev/null +++ b/apps/agent/agent/lib/run-runtime.ts @@ -0,0 +1,603 @@ +import { createHash } from "node:crypto"; +import { ActivityType, db, type Prisma } from "@crm/db"; +import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { readCompanyHistory, readDealHistory } from "./accounts"; +import { readCrmHistory } from "./crm"; +import { searchCrm } from "./lookup"; +import { lockAgentRun, runTerminalEventId } from "./run-state"; + +const ACTION_LEASE_MS = 5 * 60_000; + +type RunResource = { + kind: "integration" | "company" | "contact" | "deal"; + id: string; + label: string; +}; + +type RunRecordScope = "SELECTED" | "WORKSPACE"; + +export async function approvedRunInstructions(runId: string): Promise { + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { + status: true, + version: { select: { instructions: true } }, + }, + }); + + if (!run) throw new Error("This agent run is unavailable."); + if (run.status !== "RUNNING") { + throw new Error("This agent run is not active."); + } + return run.version.instructions; +} + +export async function runContext(runId: string) { + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { + id: true, + status: true, + triggerType: true, + input: true, + agent: { select: { id: true, name: true, description: true } }, + version: { + select: { + id: true, + number: true, + manifest: true, + modelId: true, + sandboxPolicy: true, + }, + }, + trigger: { select: { id: true, name: true, type: true, config: true } }, + }, + }); + + if (!run) throw new Error("This agent run is unavailable."); + if (run.status !== "RUNNING") { + throw new Error("This agent run is not active."); + } + + const dataScope = manifestDataScope(run.version.manifest); + return { + ...run, + recordScope: dataScope.mode, + allowedResources: dataScope.resources, + allowedActions: manifestActions(run.version.manifest), + now: new Date().toISOString(), + }; +} + +export async function queryRunCrm( + runId: string, + input: { + query: string; + kinds?: ("contact" | "company" | "deal")[]; + limit: number; + }, +) { + const run = await runContext(runId); + const scoped = run.allowedResources.filter( + (resource) => resource.kind !== "integration", + ); + const result = await searchCrm(input.query, input); + if (run.recordScope === "WORKSPACE") return result; + + const allowed = new Set( + scoped.map((resource) => `${resource.kind}:${resource.id}`), + ); + const contacts = result.contacts.filter((row) => + allowed.has(`contact:${row.id}`), + ); + const companies = result.companies.filter((row) => + allowed.has(`company:${row.id}`), + ); + const deals = result.deals.filter((row) => allowed.has(`deal:${row.id}`)); + return { + ...result, + contacts, + companies, + deals, + total: contacts.length + companies.length + deals.length, + }; +} + +export async function readRunRecord( + runId: string, + input: { + kind: "contact" | "company" | "deal"; + id: string; + }, +) { + const run = await runContext(runId); + assertResourceAllowed(run.recordScope, run.allowedResources, input); + const sources = allowedHistorySources(run.allowedResources); + + if (input.kind === "contact") + return readCrmHistory(input.id, { + threads: 10, + includeEmail: sources.gmail, + includeCalendar: sources.calendar, + }); + if (input.kind === "company") { + return readCompanyHistory(input.id, { + threads: 10, + people: 50, + includeEmail: sources.gmail, + includeCalendar: sources.calendar, + }); + } + return readDealHistory(input.id, { + threads: 10, + includeEmail: sources.gmail, + includeCalendar: sources.calendar, + }); +} + +export async function createRunActivity( + runId: string, + callId: string, + input: { + type: "NOTE" | "TASK"; + targetKind: "company" | "contact" | "deal"; + targetId: string; + subject?: string | null; + body?: string | null; + dueAt?: string | null; + }, +) { + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { + id: true, + status: true, + agentId: true, + initiatedById: true, + agent: { select: { createdById: true } }, + version: { select: { manifest: true } }, + }, + }); + if (!run) throw new Error("This agent run is unavailable."); + + assertActivityAllowed(run.version.manifest, input.type); + const dataScope = manifestDataScope(run.version.manifest); + assertResourceAllowed(dataScope.mode, dataScope.resources, { + kind: input.targetKind, + id: input.targetId, + }); + const idempotencyKey = `${runId}:${callId}`; + const requestHash = actionRequestHash(input); + const existing = await db.agentAction.findUnique({ + where: { idempotencyKey }, + select: { + id: true, + status: true, + externalId: true, + errorMessage: true, + requestHash: true, + }, + }); + if (existing) assertActionRequestMatches(existing.requestHash, requestHash); + if (existing?.status === "SUCCEEDED") { + return { + actionId: existing.id, + activityId: existing.externalId, + replayed: true, + }; + } + if (run.status !== "RUNNING") { + throw new Error("This agent run is not active."); + } + if (input.type === "TASK" && !input.subject?.trim()) { + throw new Error("A CRM task needs a subject."); + } + if (input.type === "NOTE" && !input.subject?.trim() && !input.body?.trim()) { + throw new Error("A CRM note needs a subject or body."); + } + const dueAt = input.dueAt ? new Date(input.dueAt) : null; + if (dueAt && Number.isNaN(dueAt.getTime())) { + throw new Error("The due date is invalid."); + } + const target = await targetRecord(input.targetKind, input.targetId); + if (!target) throw new Error("The requested CRM target no longer exists."); + + let action = existing; + if (!action) { + action = await db.$transaction(async (tx) => { + await lockIdempotencyKey(tx, idempotencyKey); + const winner = await tx.agentAction.findUnique({ + where: { idempotencyKey }, + select: { + id: true, + status: true, + externalId: true, + errorMessage: true, + requestHash: true, + }, + }); + if (winner) { + assertActionRequestMatches(winner.requestHash, requestHash); + return winner; + } + + return tx.agentAction.create({ + data: { + agentId: run.agentId, + runId, + type: "crm.activity.create", + provider: "crm", + targetType: input.targetKind, + targetId: input.targetId, + targetLabel: target.label, + summary: + input.subject?.trim() || + `Create a ${input.type.toLowerCase()} on ${target.label}`, + metadata: { activityType: input.type }, + idempotencyKey, + requestHash, + }, + select: { + id: true, + status: true, + externalId: true, + errorMessage: true, + requestHash: true, + }, + }); + }); + } + if (action.status === "SUCCEEDED") { + return { + actionId: action.id, + activityId: action.externalId, + replayed: true, + }; + } + + const claimed = await db.agentAction.updateMany({ + where: { + id: action.id, + OR: [ + { status: { in: ["PLANNED", "FAILED"] } }, + { + status: "RUNNING", + startedAt: { lt: new Date(Date.now() - ACTION_LEASE_MS) }, + }, + ], + }, + data: { + status: "RUNNING", + startedAt: new Date(), + completedAt: null, + attemptCount: { increment: 1 }, + errorCode: null, + errorMessage: null, + }, + }); + if (claimed.count === 0) { + const current = await db.agentAction.findUnique({ + where: { id: action.id }, + select: { status: true, externalId: true }, + }); + if (current?.status === "SUCCEEDED") { + return { + actionId: action.id, + activityId: current.externalId, + replayed: true, + }; + } + throw new Error("This agent action is already in progress."); + } + + try { + const activityId = `agent-action-${action.id}`; + const now = new Date(); + + await db.$transaction(async (tx) => { + await tx.activity.upsert({ + where: { id: activityId }, + create: { + id: activityId, + type: input.type === "TASK" ? ActivityType.TASK : ActivityType.NOTE, + subject: input.subject?.trim() || null, + body: input.body?.trim() || null, + occurredAt: now, + dueAt: input.type === "TASK" ? dueAt : null, + companyId: target.companyId, + contactId: target.contactId, + dealId: target.dealId, + createdById: run.initiatedById ?? run.agent.createdById, + meta: { + source: "agent", + agentId: run.agentId, + runId, + actionId: action.id, + }, + }, + update: {}, + }); + + if (target.companyId) { + await tx.company.update({ + where: { id: target.companyId }, + data: { lastActivityAt: now }, + }); + } + if (target.contactId) { + await tx.contact.update({ + where: { id: target.contactId }, + data: { lastActivityAt: now }, + }); + } + if (target.dealId) { + await tx.deal.update({ + where: { id: target.dealId }, + data: { lastActivityAt: now }, + }); + } + + await tx.agentAction.update({ + where: { id: action.id }, + data: { + status: "SUCCEEDED", + externalId: activityId, + completedAt: now, + }, + }); + }); + + return { actionId: action.id, activityId, replayed: false }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await db.agentAction.updateMany({ + where: { id: action.id, status: "RUNNING" }, + data: { + status: "FAILED", + errorCode: "ACTION_REJECTED", + errorMessage: message, + completedAt: new Date(), + }, + }); + throw error; + } +} + +export async function finishRun( + runId: string, + input: { summary: string; result?: Record | null }, +) { + return db.$transaction(async (tx) => { + const run = await lockAgentRun(tx, runId); + if (run.status === "SUCCEEDED") { + return { id: run.id, status: "SUCCEEDED" as const }; + } + if (run.status !== "RUNNING") { + throw new Error(`This agent run already ended with ${run.status}.`); + } + + const sequence = run.nextEventSequence + 1; + const finishedAt = new Date(); + await tx.agentRun.update({ + where: { id: runId }, + data: { + status: "SUCCEEDED", + summary: input.summary, + result: (input.result ?? {}) as Prisma.InputJsonValue, + finishedAt, + nextEventSequence: sequence, + }, + }); + await tx.agentRunEvent.create({ + data: { + id: runTerminalEventId(run.id, "completed"), + runId: run.id, + sequence, + type: "run.completed", + data: { summary: input.summary }, + emittedAt: finishedAt, + }, + }); + await tx.agentAuditEvent.upsert({ + where: { + agentId_type_requestId: { + agentId: run.agentId, + type: "run.completed", + requestId: run.id, + }, + }, + create: { + agentId: run.agentId, + versionId: run.versionId, + actorType: "AGENT", + actorId: run.id, + type: "run.completed", + summary: input.summary, + requestId: run.id, + }, + update: {}, + }); + + return { id: run.id, status: "SUCCEEDED" as const }; + }); +} + +function manifestDataScope(value: unknown): { + mode: RunRecordScope; + resources: RunResource[]; +} { + const manifest = recordOf(value); + const scope = recordOf(manifest.dataScope); + if (scope.mode !== "SELECTED" && scope.mode !== "WORKSPACE") { + throw new Error("Agent version has no valid CRM record scope."); + } + if (!Array.isArray(scope.resources)) { + throw new Error("Agent version has no valid CRM resources."); + } + + const resources = scope.resources.flatMap((resource) => { + if (!resource || typeof resource !== "object") return []; + const row = resource as Record; + if ( + !["integration", "company", "contact", "deal"].includes( + String(row.kind), + ) || + typeof row.id !== "string" || + typeof row.label !== "string" + ) { + return []; + } + return [resource as RunResource]; + }); + const records = resources.filter( + (resource) => resource.kind !== "integration", + ); + if (scope.mode === "SELECTED" && records.length === 0) { + throw new Error("Agent version selected no CRM records."); + } + if (scope.mode === "WORKSPACE" && records.length > 0) { + throw new Error("Agent version mixes workspace and selected CRM scope."); + } + return { mode: scope.mode, resources }; +} + +function manifestActions(value: unknown) { + const actions = recordOf(value).actions; + return Array.isArray(actions) ? actions.map(recordOf) : []; +} + +function assertActivityAllowed( + manifest: unknown, + activityType: "NOTE" | "TASK", +) { + const allowed = manifestActions(manifest).some( + (action) => + action.type === "crm.activity.create" && + Array.isArray(action.activityTypes) && + action.activityTypes.includes(activityType), + ); + if (!allowed) { + throw new Error( + `Agent version does not allow CRM ${activityType.toLowerCase()} activities.`, + ); + } +} + +function assertResourceAllowed( + mode: RunRecordScope, + resources: RunResource[], + input: { kind: "contact" | "company" | "deal"; id: string }, +) { + if (mode === "WORKSPACE") return; + const records = resources.filter( + (resource) => resource.kind !== "integration", + ); + if ( + records.some( + (resource) => resource.kind === input.kind && resource.id === input.id, + ) + ) { + return; + } + throw new Error( + "That CRM record is outside this agent version's approved scope.", + ); +} + +export function allowedHistorySources(resources: RunResource[]): { + gmail: boolean; + calendar: boolean; +} { + const integrations = new Set( + resources + .filter((resource) => resource.kind === "integration") + .map((resource) => resource.id), + ); + return { + gmail: integrations.has("google:gmail"), + calendar: integrations.has("google:calendar"), + }; +} + +async function targetRecord(kind: "company" | "contact" | "deal", id: string) { + if (kind === "company") { + const company = await db.company.findUnique({ + where: { id }, + select: { id: true, name: true }, + }); + return company + ? { + label: company.name, + companyId: company.id, + contactId: null, + dealId: null, + } + : null; + } + if (kind === "contact") { + const contact = await db.contact.findUnique({ + where: { id }, + select: { id: true, firstName: true, lastName: true, companyId: true }, + }); + return contact + ? { + label: [contact.firstName, contact.lastName] + .filter(Boolean) + .join(" "), + companyId: contact.companyId, + contactId: contact.id, + dealId: null, + } + : null; + } + + const deal = await db.deal.findUnique({ + where: { id }, + select: { id: true, name: true, companyId: true }, + }); + return deal + ? { + label: deal.name, + companyId: deal.companyId, + contactId: null, + dealId: deal.id, + } + : null; +} + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function actionRequestHash(input: { + type: "NOTE" | "TASK"; + targetKind: "company" | "contact" | "deal"; + targetId: string; + subject?: string | null; + body?: string | null; + dueAt?: string | null; +}): string { + return createHash("sha256") + .update( + JSON.stringify({ + type: input.type, + targetKind: input.targetKind, + targetId: input.targetId, + subject: input.subject?.trim() || null, + body: input.body?.trim() || null, + dueAt: input.dueAt?.trim() || null, + }), + ) + .digest("hex"); +} + +function assertActionRequestMatches( + existingHash: string | null, + requestHash: string, +): void { + if (existingHash !== requestHash) { + throw new Error("That agent action call was already used for other input."); + } +} diff --git a/apps/agent/agent/lib/run-state.ts b/apps/agent/agent/lib/run-state.ts new file mode 100644 index 00000000..e92f0656 --- /dev/null +++ b/apps/agent/agent/lib/run-state.ts @@ -0,0 +1,33 @@ +import type { Prisma } from "@crm/db"; +import type { AgentRunStatus } from "@crm/db/enums"; + +export type LockedAgentRun = { + id: string; + agentId: string; + versionId: string; + status: AgentRunStatus; + sessionId: string | null; + startedAt: Date | null; + nextEventSequence: number; +}; + +export async function lockAgentRun( + tx: Prisma.TransactionClient, + runId: string, +): Promise { + const [run] = await tx.$queryRaw` + SELECT id, "agentId", "versionId", status, "sessionId", "startedAt", "nextEventSequence" + FROM "agentRun" + WHERE id = ${runId} + FOR UPDATE + `; + if (!run) throw new Error("This agent run is unavailable."); + return run; +} + +export function runTerminalEventId( + runId: string, + terminal: "completed" | "failed", +) { + return `run-terminal:${runId}:${terminal}`; +} diff --git a/apps/agent/agent/lib/session-purpose.ts b/apps/agent/agent/lib/session-purpose.ts new file mode 100644 index 00000000..2b85f12d --- /dev/null +++ b/apps/agent/agent/lib/session-purpose.ts @@ -0,0 +1,69 @@ +export type SessionPurpose = "builder" | "team-agent" | "research"; + +type PurposeContext = { + readonly session: { + readonly auth: { + readonly current: { + readonly attributes: Readonly>; + } | null; + readonly initiator: { + readonly attributes: Readonly>; + } | null; + }; + }; +}; + +export function purposeOf(ctx: PurposeContext): SessionPurpose { + const purpose = attribute(ctx, "purpose"); + if (purpose === "builder" || purpose === "team-agent") return purpose; + return "research"; +} + +export function attribute(ctx: PurposeContext, key: string): string | null { + const current = ctx.session.auth.current?.attributes[key]; + if (typeof current === "string" && current.trim()) return current.trim(); + + const initiator = ctx.session.auth.initiator?.attributes[key]; + return typeof initiator === "string" && initiator.trim() + ? initiator.trim() + : null; +} + +export function requireAttribute(ctx: PurposeContext, key: string): string { + const value = attribute(ctx, key); + if (!value) throw new Error(`This session is missing ${key}.`); + return value; +} + +export function requireBuilderAttribute( + ctx: PurposeContext, + key: string, +): string { + if ( + purposeOf(ctx) !== "builder" || + attribute(ctx, "commandType") !== "CREATE_AGENT" + ) { + throw new Error( + "Agent creation requires an explicit request to create or build an agent.", + ); + } + return requireAttribute(ctx, key); +} + +export function requireTeamAgentAttribute( + ctx: PurposeContext, + key: string, +): string { + if (purposeOf(ctx) !== "team-agent") { + throw new Error( + "This deployed-agent tool is unavailable for this session.", + ); + } + return requireAttribute(ctx, key); +} + +export function assertResearchPurpose(ctx: PurposeContext): void { + if (purposeOf(ctx) !== "research") { + throw new Error("This CRM research tool is unavailable for this session."); + } +} diff --git a/apps/agent/agent/schedules/dispatch.ts b/apps/agent/agent/schedules/dispatch.ts index e1d81d65..4662099d 100644 --- a/apps/agent/agent/schedules/dispatch.ts +++ b/apps/agent/agent/schedules/dispatch.ts @@ -1,18 +1,49 @@ import { defineSchedule } from "eve/schedules"; import crm from "../channels/crm"; +import { + pendingAgentRunIds, + pendingBuilderSubmissionIds, + queueDueAgentRuns, +} from "../lib/custom-agent-dispatch"; import { brief, drainAll, taskAuth } from "../lib/dispatch"; export default defineSchedule({ cron: "* * * * *", async run({ receive, waitUntil, appAuth }) { waitUntil( - drainAll((task) => - receive(crm, { - message: brief(task), - target: { taskId: task.id }, - auth: taskAuth(task, appAuth), - }), - ), + Promise.all([ + drainAll((task) => + receive(crm, { + message: brief(task), + target: { taskId: task.id }, + auth: taskAuth(task, appAuth), + }), + ), + (async () => { + await queueDueAgentRuns(); + const [builderIds, runIds] = await Promise.all([ + pendingBuilderSubmissionIds(), + pendingAgentRunIds(), + ]); + + await Promise.all([ + ...builderIds.map((builderSubmissionId) => + receive(crm, { + message: "Continue a queued private agent-builder chat.", + target: { builderSubmissionId }, + auth: appAuth, + }), + ), + ...runIds.map((runId) => + receive(crm, { + message: "Execute a queued deployed agent run.", + target: { runId }, + auth: appAuth, + }), + ), + ]); + })(), + ]), ); }, }); diff --git a/apps/agent/agent/subagents/agent_builder/agent.ts b/apps/agent/agent/subagents/agent_builder/agent.ts new file mode 100644 index 00000000..19b75699 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/agent.ts @@ -0,0 +1,39 @@ +import { DEFAULT_AGENT_MODEL } from "@crm/db/settings"; +import { defineAgent, defineDynamic } from "eve"; +import { z } from "zod"; +import { selectedModel } from "../../lib/model"; + +export default defineAgent({ + description: + "Turn one private CRM builder-chat request into a validated, reviewable team-agent version without deploying it.", + model: defineDynamic({ + fallback: DEFAULT_AGENT_MODEL.id, + events: { "session.started": () => selectedModel() }, + }), + outputSchema: z.discriminatedUnion("status", [ + z.object({ + status: z.literal("needs_input"), + question: z.string().min(1).max(500), + options: z + .array( + z.object({ + id: z.string().min(1).max(80), + label: z.string().min(1).max(120), + }), + ) + .max(4), + allowFreeform: z.boolean(), + }), + z.object({ + status: z.literal("draft_ready"), + summary: z.string().min(1).max(1000), + agentId: z.string().min(1), + versionId: z.string().min(1), + }), + ]), + limits: { + maxInputTokensPerSession: 250_000, + maxOutputTokensPerSession: 20_000, + sessionTimeoutMs: 24 * 60 * 60 * 1000, + }, +}); diff --git a/apps/agent/agent/subagents/agent_builder/instructions.md b/apps/agent/agent/subagents/agent_builder/instructions.md new file mode 100644 index 00000000..6506c68e --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/instructions.md @@ -0,0 +1,64 @@ +# CRM agent builder + +You design one bounded internal team agent from the request delegated by the +private builder chat. + +Call `inspect_context` first. It is the authority for connected integrations, +selected CRM records, the current time, and any existing draft. Never invent a +connection or record. + +The user should not need to provide a complete specification. Treat a short +description of the job or desired outcome as enough to draft when a safe, +bounded interpretation exists. Use the inspected CRM context and existing draft +to do the design work: infer a clear name, instructions, relevant CRM record +types, and useful output. When omitted, prefer a manual trigger, no external +integration, and `run.summary` over a side effect. Use exact tagged records when +present. A request about a pipeline, workspace-wide collection, or class of CRM +records may use `WORKSPACE`; do not expand a request about one record into +workspace access. Human review of the completed draft is the place to expose +these choices. + +Make the smallest agent that solves the stated pain. Its instructions must say +exactly when it runs, which CRM records it may read, what output or CRM action +it may produce, and when it must stop. Preserve the user's meaning and wording +where that is clearer than a rewrite. + +The currently executable action types are `crm.activity.create` for CRM notes +and tasks, and `run.summary` for a logged result with no external side effect. +Gmail and Google Calendar are read-only sources when connected. Do not promise +email sending, Slack, arbitrary webhooks, or any integration the context does +not report. + +If no safe and useful draft is possible because an essential target, explicitly +requested connection, schedule, outcome, or side effect remains ambiguous, do +not call `save_agent_draft`. Return `needs_input` with one focused question for +the parent to ask the user. Include two to four mutually exclusive options when +they clarify a real choice, and set `allowFreeform` when a custom answer is +valid. Ask only when the answer materially changes the bounded behavior and the +least-privilege defaults above do not resolve it. Return exactly one decision +per pause; never bundle several missing details into one question. After the +answer, ask the next question only if the build is still materially blocked. Do +not interrupt for a name, wording, optional polish, or another choice that can +be safely represented in the reviewable draft. For a schedule, calculate a +future `nextRunAt` from the supplied current time and provide its recurrence in +minutes. + +Choose the record scope explicitly. Use `SELECTED` only for the exact tagged CRM +records reported by `inspect_context`. Use `WORKSPACE` only when the user clearly +asks for workspace-wide CRM access. Never treat an empty selected scope as +workspace access. + +For `crm.activity.create`, list the exact allowed activity types. Authorize +`NOTE`, `TASK`, or both only when the request calls for them. A prose summary +never grants an activity type by itself. + +When the behavior is specific and supported, build the agent in front of the +user. Call `write_agent_file` for `agent/instructions.md`, then +`agent/manifest.json`, then `agent/README.md`. These are durable working +revisions, so write complete useful contents and revise a file with another +call when necessary. Never put credentials, tokens, or secret values in a +file. After the three files agree, call `save_agent_draft` once with the exact +same behavior. A successful save creates exact final file snapshots and an +immutable version in READY state for human review. It does not deploy it. +Return `draft_ready` with the saved agent and version ids plus a plain-language +summary of the trigger, data scope, action, and access. diff --git a/apps/agent/agent/subagents/agent_builder/sandbox/sandbox.ts b/apps/agent/agent/subagents/agent_builder/sandbox/sandbox.ts new file mode 100644 index 00000000..a9f1c9fb --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/sandbox/sandbox.ts @@ -0,0 +1,9 @@ +import { defaultBackend, defineSandbox } from "eve/sandbox"; + +export default defineSandbox({ + backend: defaultBackend({ + vercel: { networkPolicy: "deny-all" }, + docker: { networkPolicy: "deny-all" }, + microsandbox: { networkPolicy: "deny-all" }, + }), +}); diff --git a/apps/agent/agent/subagents/agent_builder/tools/ask_question.ts b/apps/agent/agent/subagents/agent_builder/tools/ask_question.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/ask_question.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_builder/tools/bash.ts b/apps/agent/agent/subagents/agent_builder/tools/bash.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/bash.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_builder/tools/glob.ts b/apps/agent/agent/subagents/agent_builder/tools/glob.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/glob.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_builder/tools/grep.ts b/apps/agent/agent/subagents/agent_builder/tools/grep.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/grep.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_builder/tools/inspect_context.ts b/apps/agent/agent/subagents/agent_builder/tools/inspect_context.ts new file mode 100644 index 00000000..53890c44 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/inspect_context.ts @@ -0,0 +1,16 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { builderContext } from "../../../lib/builder-runtime"; +import { requireBuilderAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Read the authoritative builder-chat scope, connected sources, selected CRM records, current time, and latest draft.", + inputSchema: z.object({}), + async execute(_input, ctx) { + return builderContext( + requireBuilderAttribute(ctx, "conversationId"), + requireBuilderAttribute(ctx, "userId"), + ); + }, +}); diff --git a/apps/agent/agent/subagents/agent_builder/tools/read_file.ts b/apps/agent/agent/subagents/agent_builder/tools/read_file.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/read_file.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_builder/tools/save_agent_draft.ts b/apps/agent/agent/subagents/agent_builder/tools/save_agent_draft.ts new file mode 100644 index 00000000..3528a620 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/save_agent_draft.ts @@ -0,0 +1,57 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { saveBuilderDraft } from "../../../lib/builder-runtime"; +import { requireBuilderAttribute } from "../../../lib/session-purpose"; + +const resource = z.object({ + kind: z.enum(["integration", "company", "contact", "deal"]), + id: z.string().min(1), + label: z.string().min(1).max(120), +}); + +const trigger = z.object({ + type: z.enum(["MANUAL", "SCHEDULE"]), + name: z.string().min(1).max(120), + summary: z.string().min(1).max(240), + nextRunAt: z.string().nullish(), + intervalMinutes: z.number().int().min(1).max(525_600).nullish(), +}); + +const action = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("crm.activity.create"), + provider: z.literal("crm"), + summary: z.string().min(1).max(240), + activityTypes: z + .array(z.enum(["NOTE", "TASK"])) + .min(1) + .max(2), + }), + z.object({ + type: z.literal("run.summary"), + provider: z.literal("crm"), + summary: z.string().min(1).max(240), + }), +]); + +export default defineTool({ + description: + "Validate and save one immutable agent version for human review. This never deploys the agent.", + inputSchema: z.object({ + name: z.string().trim().min(1).max(100), + description: z.string().trim().min(1).max(320), + instructions: z.string().trim().min(40).max(20_000), + trigger, + recordScope: z.enum(["SELECTED", "WORKSPACE"]), + resources: z.array(resource).max(30), + actions: z.array(action).min(1).max(10), + access: z.array(z.string().trim().min(1).max(120)).max(20), + }), + async execute(input, ctx) { + return saveBuilderDraft( + requireBuilderAttribute(ctx, "conversationId"), + requireBuilderAttribute(ctx, "userId"), + input, + ); + }, +}); diff --git a/apps/agent/agent/subagents/agent_builder/tools/todo.ts b/apps/agent/agent/subagents/agent_builder/tools/todo.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/todo.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_builder/tools/web_fetch.ts b/apps/agent/agent/subagents/agent_builder/tools/web_fetch.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/web_fetch.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_builder/tools/web_search.ts b/apps/agent/agent/subagents/agent_builder/tools/web_search.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/web_search.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_builder/tools/write_agent_file.ts b/apps/agent/agent/subagents/agent_builder/tools/write_agent_file.ts new file mode 100644 index 00000000..8e46d38a --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/write_agent_file.ts @@ -0,0 +1,24 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { + BUILDER_ARTIFACT_PATHS, + writeBuilderArtifact, +} from "../../../lib/builder-runtime"; +import { requireBuilderAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Write one durable agent file revision so the user can follow the build live. Write instructions and the manifest before saving the final draft.", + inputSchema: z.object({ + path: z.enum(BUILDER_ARTIFACT_PATHS), + content: z.string().min(1).max(40_000), + }), + async execute(input, ctx) { + return writeBuilderArtifact( + requireBuilderAttribute(ctx, "conversationId"), + requireBuilderAttribute(ctx, "userId"), + input.path, + input.content, + ); + }, +}); diff --git a/apps/agent/agent/subagents/agent_builder/tools/write_file.ts b/apps/agent/agent/subagents/agent_builder/tools/write_file.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_builder/tools/write_file.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_runner/agent.ts b/apps/agent/agent/subagents/agent_runner/agent.ts new file mode 100644 index 00000000..8fb0964c --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/agent.ts @@ -0,0 +1,35 @@ +import { db } from "@crm/db"; +import { DEFAULT_AGENT_MODEL } from "@crm/db/settings"; +import { defineAgent, defineDynamic } from "eve"; +import { z } from "zod"; +import { attribute, purposeOf } from "../../lib/session-purpose"; + +export default defineAgent({ + description: + "Execute one immutable deployed CRM agent version and persist its result and every side effect.", + model: defineDynamic({ + fallback: DEFAULT_AGENT_MODEL.id, + events: { + "session.started": async (_event, ctx) => { + if (purposeOf(ctx) !== "team-agent") return null; + const runId = attribute(ctx, "runId"); + if (!runId) return null; + + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { version: { select: { modelId: true } } }, + }); + return run?.version.modelId ?? null; + }, + }, + }), + outputSchema: z.object({ + summary: z.string().min(1).max(1000), + result: z.record(z.string(), z.unknown()).nullable(), + }), + limits: { + maxInputTokensPerSession: 500_000, + maxOutputTokensPerSession: 40_000, + sessionTimeoutMs: 24 * 60 * 60 * 1000, + }, +}); diff --git a/apps/agent/agent/subagents/agent_runner/instructions.md b/apps/agent/agent/subagents/agent_runner/instructions.md new file mode 100644 index 00000000..850a703a --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/instructions.md @@ -0,0 +1,24 @@ +# Deployed CRM agent runner + +Execute exactly one pinned team-agent run. + +The approved version instructions are supplied as system instructions at +session start. Call `inspect_run` first for its immutable manifest, trigger, +approved scope, allowed actions, and current time. Follow the approved business +intent only through the tools exposed here. Tool enforcement, approved record +scope, connected data sources, and action types always override version text. + +Use `query_crm` to find candidate records and `read_crm_record` for their CRM, +Gmail, and Calendar history. Those sources are read-only. Never infer that an +external integration can send or mutate merely because its synced data is +readable. + +`create_crm_activity` is the only current side-effecting tool. Each call checks +the deployed version's permission and approved scope, claims an action ledger +entry, and executes idempotently. Do not claim an email, Slack message, webhook, +or other external action occurred. + +Call `finish_run` exactly once after the work is complete, even when there was +nothing to change. Give a concise factual summary and a small structured result. +Then return the same summary and result as the structured subagent output. Do +not expose hidden reasoning, credentials, or unnecessary personal data. diff --git a/apps/agent/agent/subagents/agent_runner/instructions/run.ts b/apps/agent/agent/subagents/agent_runner/instructions/run.ts new file mode 100644 index 00000000..d9af57b2 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/instructions/run.ts @@ -0,0 +1,17 @@ +import { defineDynamic, defineInstructions } from "eve/instructions"; +import { approvedRunInstructions } from "../../../lib/run-runtime"; +import { attribute, purposeOf } from "../../../lib/session-purpose"; + +export default defineDynamic({ + events: { + "session.started": async (_event, ctx) => { + if (purposeOf(ctx) !== "team-agent") return null; + const runId = attribute(ctx, "runId"); + if (!runId) return null; + + return defineInstructions({ + markdown: `# Human-approved version instructions\n\n${await approvedRunInstructions(runId)}`, + }); + }, + }, +}); diff --git a/apps/agent/agent/subagents/agent_runner/sandbox/sandbox.ts b/apps/agent/agent/subagents/agent_runner/sandbox/sandbox.ts new file mode 100644 index 00000000..a9f1c9fb --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/sandbox/sandbox.ts @@ -0,0 +1,9 @@ +import { defaultBackend, defineSandbox } from "eve/sandbox"; + +export default defineSandbox({ + backend: defaultBackend({ + vercel: { networkPolicy: "deny-all" }, + docker: { networkPolicy: "deny-all" }, + microsandbox: { networkPolicy: "deny-all" }, + }), +}); diff --git a/apps/agent/agent/subagents/agent_runner/tools/ask_question.ts b/apps/agent/agent/subagents/agent_runner/tools/ask_question.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/ask_question.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_runner/tools/bash.ts b/apps/agent/agent/subagents/agent_runner/tools/bash.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/bash.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_runner/tools/create_crm_activity.ts b/apps/agent/agent/subagents/agent_runner/tools/create_crm_activity.ts new file mode 100644 index 00000000..f510e7bb --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/create_crm_activity.ts @@ -0,0 +1,24 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { createRunActivity } from "../../../lib/run-runtime"; +import { requireTeamAgentAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Create an approved internal CRM note or task on an approved record. The version must allow the exact activity type. The action is logged before it executes and is idempotent across retries.", + inputSchema: z.object({ + type: z.enum(["NOTE", "TASK"]), + targetKind: z.enum(["company", "contact", "deal"]), + targetId: z.string().min(1), + subject: z.string().trim().max(240).nullish(), + body: z.string().trim().max(10_000).nullish(), + dueAt: z.string().nullish(), + }), + async execute(input, ctx) { + return createRunActivity( + requireTeamAgentAttribute(ctx, "runId"), + ctx.callId, + input, + ); + }, +}); diff --git a/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts b/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts new file mode 100644 index 00000000..5da8eb52 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts @@ -0,0 +1,16 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { finishRun } from "../../../lib/run-runtime"; +import { requireTeamAgentAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Finish this run successfully with its concise summary and structured result.", + inputSchema: z.object({ + summary: z.string().trim().min(1).max(1000), + result: z.record(z.string(), z.unknown()).nullish(), + }), + async execute(input, ctx) { + return finishRun(requireTeamAgentAttribute(ctx, "runId"), input); + }, +}); diff --git a/apps/agent/agent/subagents/agent_runner/tools/glob.ts b/apps/agent/agent/subagents/agent_runner/tools/glob.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/glob.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_runner/tools/grep.ts b/apps/agent/agent/subagents/agent_runner/tools/grep.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/grep.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_runner/tools/inspect_run.ts b/apps/agent/agent/subagents/agent_runner/tools/inspect_run.ts new file mode 100644 index 00000000..1ae44c21 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/inspect_run.ts @@ -0,0 +1,13 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { runContext } from "../../../lib/run-runtime"; +import { requireTeamAgentAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Read the immutable version manifest, trigger, approved scope, allowed actions, and current time for this run.", + inputSchema: z.object({}), + async execute(_input, ctx) { + return runContext(requireTeamAgentAttribute(ctx, "runId")); + }, +}); diff --git a/apps/agent/agent/subagents/agent_runner/tools/query_crm.ts b/apps/agent/agent/subagents/agent_runner/tools/query_crm.ts new file mode 100644 index 00000000..34f762e0 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/query_crm.ts @@ -0,0 +1,17 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { queryRunCrm } from "../../../lib/run-runtime"; +import { requireTeamAgentAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Search contacts, companies, and deals inside this deployed version's approved CRM scope.", + inputSchema: z.object({ + query: z.string().trim().min(2).max(160), + kinds: z.array(z.enum(["contact", "company", "deal"])).optional(), + limit: z.number().int().min(1).max(50).default(20), + }), + async execute(input, ctx) { + return queryRunCrm(requireTeamAgentAttribute(ctx, "runId"), input); + }, +}); diff --git a/apps/agent/agent/subagents/agent_runner/tools/read_crm_record.ts b/apps/agent/agent/subagents/agent_runner/tools/read_crm_record.ts new file mode 100644 index 00000000..ec77d347 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/read_crm_record.ts @@ -0,0 +1,16 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { readRunRecord } from "../../../lib/run-runtime"; +import { requireTeamAgentAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Read one approved CRM record with its CRM history and only the connected email or calendar sources approved by this version.", + inputSchema: z.object({ + kind: z.enum(["contact", "company", "deal"]), + id: z.string().min(1), + }), + async execute(input, ctx) { + return readRunRecord(requireTeamAgentAttribute(ctx, "runId"), input); + }, +}); diff --git a/apps/agent/agent/subagents/agent_runner/tools/read_file.ts b/apps/agent/agent/subagents/agent_runner/tools/read_file.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/read_file.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_runner/tools/todo.ts b/apps/agent/agent/subagents/agent_runner/tools/todo.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/todo.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_runner/tools/web_fetch.ts b/apps/agent/agent/subagents/agent_runner/tools/web_fetch.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/web_fetch.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_runner/tools/web_search.ts b/apps/agent/agent/subagents/agent_runner/tools/web_search.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/web_search.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/subagents/agent_runner/tools/write_file.ts b/apps/agent/agent/subagents/agent_runner/tools/write_file.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/write_file.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/tools/agent.ts b/apps/agent/agent/tools/agent.ts new file mode 100644 index 00000000..04bd0544 --- /dev/null +++ b/apps/agent/agent/tools/agent.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/apps/agent/agent/tools/enrich_company.ts b/apps/agent/agent/tools/enrich_company.ts index 5b034c56..58fc2acd 100644 --- a/apps/agent/agent/tools/enrich_company.ts +++ b/apps/agent/agent/tools/enrich_company.ts @@ -2,6 +2,7 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; import { runBrand } from "../lib/brand"; import { spend } from "../lib/focus"; +import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: @@ -15,7 +16,8 @@ export default defineTool({ "Bypass the vendor's ~90-day cache. Only when a rep has asked for a fresh look.", ), }), - async execute({ companyId, fresh }) { + async execute({ companyId, fresh }, ctx) { + assertResearchPurpose(ctx); const result = await runBrand({ companyId, fresh, spend }); if (!result.enriched) { diff --git a/apps/agent/agent/tools/fetch_contact_photo.ts b/apps/agent/agent/tools/fetch_contact_photo.ts index 8b8f6f04..46999e86 100644 --- a/apps/agent/agent/tools/fetch_contact_photo.ts +++ b/apps/agent/agent/tools/fetch_contact_photo.ts @@ -3,6 +3,7 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; import { spend } from "../lib/focus"; import { runPortrait } from "../lib/portrait"; +import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: @@ -14,7 +15,8 @@ export default defineTool({ .default(false) .describe("Replace an existing photo. Only when a rep asked."), }), - async execute({ contactId, force }) { + async execute({ contactId, force }, ctx) { + assertResearchPurpose(ctx); if (!blobEnabled()) { return { stored: false as const, diff --git a/apps/agent/agent/tools/identify_contact.ts b/apps/agent/agent/tools/identify_contact.ts index 015e8e3b..e31f7d28 100644 --- a/apps/agent/agent/tools/identify_contact.ts +++ b/apps/agent/agent/tools/identify_contact.ts @@ -4,6 +4,7 @@ import type { Evidence, EvidenceKind } from "../lib/evidence"; import { WEIGHTS } from "../lib/evidence"; import { recordFact } from "../lib/facts"; import { focusOn } from "../lib/focus"; +import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: @@ -24,7 +25,8 @@ export default defineTool({ .min(1), sourceUrl: z.string().describe("The page a rep should open to check."), }), - async execute(input) { + async execute(input, ctx) { + assertResearchPurpose(ctx); focusOn({ contactId: input.contactId }); const result = await recordFact({ diff --git a/apps/agent/agent/tools/list_deals.ts b/apps/agent/agent/tools/list_deals.ts new file mode 100644 index 00000000..1c0644f0 --- /dev/null +++ b/apps/agent/agent/tools/list_deals.ts @@ -0,0 +1,46 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { listDeals } from "../lib/lookup"; + +export default defineTool({ + description: + "List deals across the CRM with pipeline status and inactivity filters. Use this for broad requests such as all open deals, stale deals, deals untouched for a number of days, or a pipeline sweep. Results are oldest-touch first and paginated; continue with nextCursor while hasMore is true. Free.", + inputSchema: z.object({ + status: z.enum(["open", "won", "lost", "all"]).default("open"), + inactiveForDays: z + .number() + .int() + .min(0) + .max(3650) + .optional() + .describe( + "Return deals whose last activity was at least this many days ago. Deals with no activity qualify once they are this old.", + ), + companyId: z.string().optional(), + ownerId: z.string().optional(), + limit: z.number().int().min(1).max(100).default(50), + cursor: z.string().optional(), + }), + async execute(input) { + return listDeals(input); + }, + toModelOutput(output) { + return { + type: "json", + value: { + ...output, + deals: output.deals.map((deal) => ({ + ...deal, + company: { id: deal.company.id, name: deal.company.name }, + owner: deal.owner + ? { + id: deal.owner.id, + name: deal.owner.name, + email: deal.owner.email, + } + : null, + })), + }, + }; + }, +}); diff --git a/apps/agent/agent/tools/record_fact.ts b/apps/agent/agent/tools/record_fact.ts index 1f4925d8..899400f2 100644 --- a/apps/agent/agent/tools/record_fact.ts +++ b/apps/agent/agent/tools/record_fact.ts @@ -4,6 +4,7 @@ import type { Evidence, EvidenceKind } from "../lib/evidence"; import { WEIGHTS } from "../lib/evidence"; import { FACT_FIELDS, type FactField, recordFact } from "../lib/facts"; import { focusOn } from "../lib/focus"; +import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: @@ -44,7 +45,8 @@ export default defineTool({ .optional() .describe("The page a rep should open to check."), }), - async execute(input) { + async execute(input, ctx) { + assertResearchPurpose(ctx); focusOn({ contactId: input.contactId }); const result = await recordFact({ diff --git a/apps/agent/agent/tools/record_job_change.ts b/apps/agent/agent/tools/record_job_change.ts index f6b6688e..d7fdbb72 100644 --- a/apps/agent/agent/tools/record_job_change.ts +++ b/apps/agent/agent/tools/record_job_change.ts @@ -5,6 +5,7 @@ import { sensitiveWrite } from "../lib/approval"; import { writeTimelineNote } from "../lib/crm"; import { lastEmployerChange } from "../lib/facts"; import { focusOn } from "../lib/focus"; +import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: @@ -21,7 +22,8 @@ export default defineTool({ approval: sensitiveWrite( "Raise the change without `moveToCompanyId` — the alert lands on the timeline and their owner decides whether to move them.", ), - async execute({ contactId, moveToCompanyId }) { + async execute({ contactId, moveToCompanyId }, ctx) { + assertResearchPurpose(ctx); focusOn({ contactId }); const change = await lastEmployerChange(contactId); diff --git a/apps/agent/agent/tools/schedule_recheck.ts b/apps/agent/agent/tools/schedule_recheck.ts index 53af62b0..fa918891 100644 --- a/apps/agent/agent/tools/schedule_recheck.ts +++ b/apps/agent/agent/tools/schedule_recheck.ts @@ -1,6 +1,7 @@ import { PRIORITY } from "@crm/db/agent-tasks"; import { defineTool } from "eve/tools"; import { z } from "zod"; +import { assertResearchPurpose } from "../lib/session-purpose"; import { scheduleTask } from "../lib/tasks"; const MIN_DAYS = 1; @@ -33,7 +34,8 @@ export default defineTool({ .default(4) .describe("Vendor calls the next run may spend."), }), - async execute({ contactId, days, reason, budget }) { + async execute({ contactId, days, reason, budget }, ctx) { + assertResearchPurpose(ctx); const dueAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); await scheduleTask({ diff --git a/apps/agent/agent/tools/set_chat_title.ts b/apps/agent/agent/tools/set_chat_title.ts new file mode 100644 index 00000000..9ad62cf3 --- /dev/null +++ b/apps/agent/agent/tools/set_chat_title.ts @@ -0,0 +1,26 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { + BUILDER_CONVERSATION_TITLE_MAX_LENGTH, + setBuilderConversationTitle, +} from "../lib/conversation-title"; +import { purposeOf, requireAttribute } from "../lib/session-purpose"; + +export default defineTool({ + description: + "Set the concise title for a new private builder chat. Available only when the current turn says the chat needs a title.", + inputSchema: z.object({ + title: z.string().trim().min(1).max(BUILDER_CONVERSATION_TITLE_MAX_LENGTH), + }), + async execute({ title }, ctx) { + if (purposeOf(ctx) !== "builder") { + throw new Error("Chat titles can only be set in builder conversations."); + } + + return setBuilderConversationTitle( + requireAttribute(ctx, "conversationId"), + requireAttribute(ctx, "userId"), + title, + ); + }, +}); diff --git a/apps/agent/agent/tools/set_contact_socials.ts b/apps/agent/agent/tools/set_contact_socials.ts index 28ac10f1..8011c730 100644 --- a/apps/agent/agent/tools/set_contact_socials.ts +++ b/apps/agent/agent/tools/set_contact_socials.ts @@ -4,6 +4,7 @@ import { personForVerification } from "../lib/crm"; import type { Evidence } from "../lib/evidence"; import { recordFact } from "../lib/facts"; import { focusOn } from "../lib/focus"; +import { assertResearchPurpose } from "../lib/session-purpose"; import { parseSocialUrl, verifyGithub, verifyX } from "../lib/socials"; export default defineTool({ @@ -22,7 +23,8 @@ export default defineTool({ "A candidate github.com profile URL from find_contact_socials.", ), }), - async execute({ contactId, twitterUrl, githubUrl }) { + async execute({ contactId, twitterUrl, githubUrl }, ctx) { + assertResearchPurpose(ctx); focusOn({ contactId }); const person = await personForVerification(contactId); diff --git a/apps/agent/agent/tools/write_brief.ts b/apps/agent/agent/tools/write_brief.ts index 95f0684a..190d1f04 100644 --- a/apps/agent/agent/tools/write_brief.ts +++ b/apps/agent/agent/tools/write_brief.ts @@ -4,6 +4,7 @@ import type { Evidence, EvidenceKind } from "../lib/evidence"; import { WEIGHTS } from "../lib/evidence"; import { writeBrief } from "../lib/facts"; import { focusOn } from "../lib/focus"; +import { assertResearchPurpose } from "../lib/session-purpose"; const MAX_NARRATIVE = 400; @@ -47,7 +48,8 @@ export default defineTool({ .min(1), sourceUrl: z.string().optional(), }), - async execute(input) { + async execute(input, ctx) { + assertResearchPurpose(ctx); focusOn({ contactId: input.contactId }); const narrative = input.narrative.trim(); diff --git a/apps/agent/agent/tools/write_workspace_profile.ts b/apps/agent/agent/tools/write_workspace_profile.ts index 0c94a313..5618f357 100644 --- a/apps/agent/agent/tools/write_workspace_profile.ts +++ b/apps/agent/agent/tools/write_workspace_profile.ts @@ -7,6 +7,7 @@ import { import { defineTool } from "eve/tools"; import { z } from "zod"; import { currentFocus } from "../lib/focus"; +import { assertResearchPurpose } from "../lib/session-purpose"; import { identity } from "../lib/workspace"; const line = (what: string) => @@ -35,7 +36,8 @@ export default defineTool({ ), sourceUrl: z.string().optional(), }), - async execute(input) { + async execute(input, ctx) { + assertResearchPurpose(ctx); const us = await identity(); if (!us?.website) { diff --git a/apps/agent/evals/agent-builder.eval.ts b/apps/agent/evals/agent-builder.eval.ts new file mode 100644 index 00000000..f32c620e --- /dev/null +++ b/apps/agent/evals/agent-builder.eval.ts @@ -0,0 +1,174 @@ +import { db } from "@crm/db"; +import { defineEval } from "eve/evals"; +import { equals, satisfies } from "eve/evals/expect"; + +export default defineEval({ + description: + "Turn a short outcome request into a safe, review-only team agent through the durable CRM channel.", + tags: ["builder", "integration"], + timeoutMs: 180_000, + async test(t) { + const secret = process.env.AGENT_BRIDGE_SECRET?.trim(); + if ( + !process.env.DATABASE_URL || + !secret || + (!process.env.AI_GATEWAY_API_KEY && !process.env.VERCEL_OIDC_TOKEN) + ) { + t.skip( + "Requires DATABASE_URL, AGENT_BRIDGE_SECRET, and an AI Gateway credential.", + ); + return; + } + + const suffix = crypto.randomUUID(); + const userId = `builder-eval-user-${suffix}`; + let conversationId: string | null = null; + let sessionId: string | null = null; + + try { + await db.user.create({ + data: { + id: userId, + name: "Agent Builder Eval", + email: `${userId}@example.test`, + }, + }); + const conversation = await db.agentConversation.create({ + data: { + kind: "BUILDER", + userId, + submissions: { + create: { + submittedById: userId, + clientRequestId: crypto.randomUUID(), + commandType: "CREATE_AGENT", + message: { + text: "Build me an agent that gives me a snapshot of our CRM accounts.", + resources: [], + }, + }, + }, + }, + select: { id: true }, + }); + conversationId = conversation.id; + + const response = await t.target.fetch("/internal/crm/builder-dispatch", { + method: "POST", + headers: { authorization: `Bearer ${secret}` }, + }); + await t.require(response.status, equals(202)); + + sessionId = await waitForBuilderSession(conversation.id, t.signal); + await t.require( + sessionId, + satisfies( + (value) => typeof value === "string", + "builder session started", + ), + ); + const session = await t.target.attachSession(sessionId as string); + session.succeeded(); + session.calledSubagent("agent_builder", { count: 1 }); + session.notCalledTool("record_fact"); + session.notCalledTool("record_job_change"); + + const saved = await db.agentConversation.findUniqueOrThrow({ + where: { id: conversation.id }, + select: { + agent: { + select: { + status: true, + versions: { + orderBy: { number: "desc" }, + take: 1, + select: { status: true, manifest: true }, + }, + }, + }, + builderArtifacts: { + where: { status: "READY" }, + select: { path: true }, + }, + }, + }); + t.check(saved.agent?.status, equals("DRAFT")); + t.check(saved.agent?.versions[0]?.status, equals("READY")); + t.check(saved.builderArtifacts.length, equals(3)); + t.check( + saved.agent?.versions[0]?.manifest, + satisfies((value) => { + const manifest = recordOf(value); + const scope = recordOf(manifest.dataScope); + const actions = Array.isArray(manifest.actions) + ? manifest.actions.map(recordOf) + : []; + return ( + scope.mode === "WORKSPACE" && + actions.length === 1 && + actions[0]?.type === "run.summary" + ); + }, "saved manifest is workspace-scoped and side-effect-free"), + ); + } finally { + await cleanupBuilderEval(userId, conversationId, sessionId); + } + }, +}); + +async function waitForBuilderSession( + conversationId: string, + signal: AbortSignal, +): Promise { + for (let attempt = 0; attempt < 600; attempt += 1) { + if (signal.aborted) return null; + const conversation = await db.agentConversation.findUnique({ + where: { id: conversationId }, + select: { sessionId: true }, + }); + if (conversation?.sessionId) return conversation.sessionId; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return null; +} + +async function cleanupBuilderEval( + userId: string, + conversationId: string | null, + sessionId: string | null, +) { + const agents = await db.agentDefinition.findMany({ + where: { createdById: userId }, + select: { id: true }, + }); + const agentIds = agents.map((agent) => agent.id); + if (sessionId) await db.agentEvent.deleteMany({ where: { sessionId } }); + if (conversationId) { + await db.agentBuilderArtifact.deleteMany({ where: { conversationId } }); + } + if (agentIds.length > 0) { + await db.agentBuilderArtifact.deleteMany({ + where: { version: { agentId: { in: agentIds } } }, + }); + await db.agentAuditEvent.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentTrigger.deleteMany({ where: { agentId: { in: agentIds } } }); + await db.agentDefinition.updateMany({ + where: { id: { in: agentIds } }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ where: { agentId: { in: agentIds } } }); + await db.agentDefinition.deleteMany({ where: { id: { in: agentIds } } }); + } + if (conversationId) { + await db.agentConversation.deleteMany({ where: { id: conversationId } }); + } + await db.user.deleteMany({ where: { id: userId } }); +} + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} diff --git a/apps/agent/evals/evals.config.ts b/apps/agent/evals/evals.config.ts new file mode 100644 index 00000000..b482d055 --- /dev/null +++ b/apps/agent/evals/evals.config.ts @@ -0,0 +1,6 @@ +import { defineEvalConfig } from "eve/evals"; + +export default defineEvalConfig({ + maxConcurrency: 1, + timeoutMs: 180_000, +}); diff --git a/apps/agent/package.json b/apps/agent/package.json index 0c437819..b65d0454 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -6,13 +6,14 @@ "license": "MIT", "scripts": { "backfill:images": "bun scripts/backfill-brand-images.ts", - "dev": "eve dev --no-ui", - "dev:tui": "eve dev", + "dev": "eve dev", + "dev:headless": "eve dev --no-ui", "dispatch": "curl -fsS -X POST \"${AGENT_URL:-http://127.0.0.1:2000}/eve/v1/dev/schedules/dispatch\"", "build": "eve build", - "start": "eve start", + "start": "bun scripts/start.ts", "check-types": "tsc --noEmit", "test": "CRM_TELEMETRY_DISABLED=1 bun test", + "eval": "CRM_TELEMETRY_DISABLED=1 eve eval", "lint": "biome check .", "clean": "rm -rf .turbo .eve node_modules" }, @@ -27,6 +28,7 @@ "devDependencies": { "@crm/typescript-config": "workspace:*", "@types/node": "^24.0.0", + "just-bash": "^3.2.0", "microsandbox": "^0.6.8", "typescript": "^5.9.2" } diff --git a/apps/agent/scripts/start.ts b/apps/agent/scripts/start.ts new file mode 100644 index 00000000..d07ede4a --- /dev/null +++ b/apps/agent/scripts/start.ts @@ -0,0 +1,28 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; + +const rawPort = process.env.AGENT_PORT ?? process.env.PORT ?? "2000"; +const port = Number(rawPort); + +if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error( + `AGENT_PORT or PORT must be a valid port, received ${rawPort}.`, + ); +} + +const cli = resolve(import.meta.dir, "../node_modules/eve/bin/eve.js"); +const child = spawn("node", [cli, "start", "--port", String(port)], { + stdio: "inherit", + env: process.env, +}); + +const forward = (signal: NodeJS.Signals) => { + if (!child.killed) child.kill(signal); +}; + +process.once("SIGINT", forward); +process.once("SIGTERM", forward); + +child.once("exit", (code) => { + process.exit(code ?? 1); +}); diff --git a/apps/agent/test/accounts.integration.spec.ts b/apps/agent/test/accounts.integration.spec.ts index e66747c7..536eaf0e 100644 --- a/apps/agent/test/accounts.integration.spec.ts +++ b/apps/agent/test/accounts.integration.spec.ts @@ -257,6 +257,25 @@ describe("readCompanyHistory", () => { ]); }); + it("omits connected history when the caller did not approve those sources", async () => { + const history = await readCompanyHistory(companyId, { + includeEmail: false, + includeCalendar: false, + }); + + expect(history?.threads).toEqual([]); + expect(history?.meetings).toEqual([]); + expect(history?.stats.emails).toBe(0); + expect(history?.stats.meetings).toBe(0); + expect(history?.stats.lastReplyAt).toBeNull(); + expect(history?.stats.nextMeetingAt).toBeNull(); + expect( + history?.people.every( + (person) => person.threads === 0 && person.meetings === 0, + ), + ).toBe(true); + }); + it("returns null for a company that does not exist", async () => { expect(await readCompanyHistory("nope")).toBeNull(); }); @@ -303,6 +322,19 @@ describe("readDealHistory", () => { expect(history?.note).toContain("never against a deal"); }); + it("omits deal correspondence when connected sources are not approved", async () => { + const history = await readDealHistory(dealId, { + includeEmail: false, + includeCalendar: false, + }); + + expect(history?.threads).toEqual([]); + expect(history?.meetings).toEqual([]); + expect(history?.stats.theyReplied).toBe(false); + expect(history?.stats.nextMeetingAt).toBeNull(); + expect(history?.note).toContain("outside this agent version"); + }); + it("returns null for a deal that does not exist", async () => { expect(await readDealHistory("nope")).toBeNull(); }); diff --git a/apps/agent/test/builder-runtime.integration.spec.ts b/apps/agent/test/builder-runtime.integration.spec.ts new file mode 100644 index 00000000..6a85eaad --- /dev/null +++ b/apps/agent/test/builder-runtime.integration.spec.ts @@ -0,0 +1,314 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { + saveBuilderDraft, + writeBuilderArtifact, +} from "../agent/lib/builder-runtime"; +import { setBuilderConversationTitle } from "../agent/lib/conversation-title"; + +const suffix = crypto.randomUUID(); +const userId = `builder-runtime-user-${suffix}`; +let conversationId = ""; +let agentId = ""; +const conversationIds: string[] = []; + +beforeAll(async () => { + await db.user.create({ + data: { + id: userId, + name: "Builder Runtime Test", + email: `${userId}@example.test`, + }, + }); + const conversation = await db.agentConversation.create({ + data: { kind: "BUILDER", userId }, + select: { id: true }, + }); + conversationId = conversation.id; + conversationIds.push(conversation.id); +}); + +afterAll(async () => { + const agentIds = ( + await db.agentDefinition.findMany({ + where: { createdById: userId }, + select: { id: true }, + }) + ).map((agent) => agent.id); + if (agentIds.length > 0) { + await db.agentBuilderArtifact.deleteMany({ + where: { + OR: [ + { conversationId: { in: conversationIds } }, + { version: { agentId: { in: agentIds } } }, + ], + }, + }); + await db.agentAuditEvent.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentTrigger.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentDefinition.updateMany({ + where: { id: { in: agentIds } }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentDefinition.deleteMany({ + where: { id: { in: agentIds } }, + }); + } + await db.agentConversation.deleteMany({ + where: { id: { in: conversationIds } }, + }); + await db.user.deleteMany({ where: { id: userId } }); +}); + +describe("builder persistence", () => { + it("sets a concise model-authored title only once", async () => { + const conversation = await db.agentConversation.create({ + data: { kind: "BUILDER", userId }, + select: { id: true }, + }); + conversationIds.push(conversation.id); + + expect( + await setBuilderConversationTitle( + conversation.id, + userId, + " “Flag stale pipeline deals” ", + ), + ).toEqual({ saved: true, title: "Flag stale pipeline deals" }); + expect( + await setBuilderConversationTitle( + conversation.id, + userId, + "Replace the title", + ), + ).toEqual({ saved: false, title: "Flag stale pipeline deals" }); + }); + + it("serializes concurrent file writes and draft saves", async () => { + const instructions = + "When manually triggered, review the approved CRM scope and write a concise run summary without changing external systems."; + const writes = await Promise.all( + Array.from({ length: 4 }, () => + writeBuilderArtifact( + conversationId, + userId, + "agent/instructions.md", + `${instructions}\n`, + ), + ), + ); + + expect(new Set(writes.map((write) => write.id)).size).toBe(1); + expect(new Set(writes.map((write) => write.revision))).toEqual( + new Set([1]), + ); + + const input = { + name: "Meeting prep", + description: "Prepare a concise CRM meeting brief.", + instructions, + trigger: { + type: "MANUAL" as const, + name: "Manual", + summary: "Run when a rep requests meeting preparation.", + }, + recordScope: "WORKSPACE" as const, + resources: [], + actions: [ + { + type: "run.summary" as const, + provider: "crm" as const, + summary: "Write a run summary.", + }, + ], + access: ["Read CRM records in the approved scope"], + }; + const saves = await Promise.all( + Array.from({ length: 4 }, () => + saveBuilderDraft(conversationId, userId, input), + ), + ); + const saved = saves.flatMap((save) => (save.saved ? [save] : [])); + + expect(saved).toHaveLength(4); + expect(new Set(saved.map((save) => save.agentId)).size).toBe(1); + expect(new Set(saved.map((save) => save.versionId)).size).toBe(1); + agentId = saved[0]?.agentId ?? ""; + expect(await db.agentDefinition.count({ where: { id: agentId } })).toBe(1); + expect(await db.agentVersion.count({ where: { agentId } })).toBe(1); + + const artifact = await db.agentBuilderArtifact.findFirstOrThrow({ + where: { conversationId, path: "agent/instructions.md" }, + orderBy: { revision: "desc" }, + select: { revision: true, status: true, versionId: true }, + }); + expect(artifact).toEqual({ + revision: 1, + status: "READY", + versionId: saved[0]?.versionId, + }); + }); + + it("fails closed on unsupported integrations and ambiguous record scope", async () => { + const conversation = await db.agentConversation.create({ + data: { kind: "BUILDER", userId }, + select: { id: true }, + }); + conversationIds.push(conversation.id); + const base = { + name: "Safe scope", + description: "Keep a bounded CRM summary.", + instructions: + "When manually triggered, read only the approved CRM scope and return a concise summary without changing CRM records.", + trigger: { + type: "MANUAL" as const, + name: "Manual", + summary: "Run only when a teammate requests it.", + }, + actions: [ + { + type: "run.summary" as const, + provider: "crm" as const, + summary: "Return a run summary.", + }, + ], + access: ["Read approved CRM records"], + }; + + const unsupported = await saveBuilderDraft(conversation.id, userId, { + ...base, + recordScope: "WORKSPACE", + resources: [ + { kind: "integration", id: "google:drive", label: "Google Drive" }, + ], + }); + expect(unsupported).toMatchObject({ + saved: false, + issues: ["Google Drive is not an available integration."], + }); + + const ambiguous = await saveBuilderDraft(conversation.id, userId, { + ...base, + recordScope: "SELECTED", + resources: [], + }); + expect(ambiguous).toMatchObject({ + saved: false, + issues: ["Selected CRM scope needs at least one tagged record."], + }); + }); + + it("keeps a live definition unchanged until a revised version is deployed", async () => { + const conversation = await db.agentConversation.create({ + data: { kind: "BUILDER", userId }, + select: { id: true }, + }); + conversationIds.push(conversation.id); + + const original = { + name: "Workspace pulse", + description: "Report the workspace company count.", + instructions: + "When manually triggered, read workspace companies and return the company count in a concise run summary without changing CRM records.", + trigger: { + type: "MANUAL" as const, + name: "Manual", + summary: "Run when a teammate requests a workspace pulse.", + }, + recordScope: "WORKSPACE" as const, + resources: [], + actions: [ + { + type: "run.summary" as const, + provider: "crm" as const, + summary: "Write a run summary.", + }, + ], + access: ["Read workspace CRM records"], + }; + const first = await saveBuilderDraft(conversation.id, userId, original); + if (!first.saved) throw new Error("Initial draft was not saved"); + + await db.$transaction([ + db.agentVersion.update({ + where: { id: first.versionId }, + data: { status: "DEPLOYED" }, + }), + db.agentDefinition.update({ + where: { id: first.agentId }, + data: { + name: "Workspace pulse", + description: "Team-visible description", + status: "LIVE", + currentVersionId: first.versionId, + }, + }), + ]); + + const revised = { + ...original, + name: "Workspace health pulse", + description: "Report company and open-deal counts.", + }; + await writeBuilderArtifact( + conversation.id, + userId, + "agent/README.md", + `# ${revised.name}\n\n${revised.description}\n\n## Trigger\n\n${revised.trigger.summary}\n\n## Access\n\n- ${revised.access[0]}\n`, + ); + const second = await saveBuilderDraft(conversation.id, userId, revised); + if (!second.saved) throw new Error("Revised draft was not saved"); + + const [definition, version, artifacts] = await Promise.all([ + db.agentDefinition.findUniqueOrThrow({ + where: { id: first.agentId }, + select: { + name: true, + description: true, + status: true, + currentVersionId: true, + }, + }), + db.agentVersion.findUniqueOrThrow({ + where: { id: second.versionId }, + select: { number: true, status: true, manifest: true }, + }), + db.agentBuilderArtifact.findMany({ + where: { conversationId: conversation.id, versionId: second.versionId }, + select: { path: true, status: true }, + }), + ]); + + expect(second.versionId).not.toBe(first.versionId); + expect(definition).toEqual({ + name: "Workspace pulse", + description: "Team-visible description", + status: "LIVE", + currentVersionId: first.versionId, + }); + expect(version).toMatchObject({ + number: 2, + status: "READY", + manifest: { + name: revised.name, + description: revised.description, + }, + }); + expect(artifacts).toHaveLength(3); + expect(artifacts.every((artifact) => artifact.status === "READY")).toBe( + true, + ); + expect( + await db.agentBuilderArtifact.count({ + where: { conversationId: conversation.id, status: "WRITING" }, + }), + ).toBe(0); + }); +}); diff --git a/apps/agent/test/custom-agent-runtime.spec.ts b/apps/agent/test/custom-agent-runtime.spec.ts new file mode 100644 index 00000000..e3f28e1f --- /dev/null +++ b/apps/agent/test/custom-agent-runtime.spec.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "bun:test"; +import { builderTaskMarkdown } from "../agent/instructions/task"; +import { + builderDeliveryMessage, + builderIdFromToken, + builderToken, + runIdFromToken, + runToken, +} from "../agent/lib/custom-agent-dispatch"; +import { allowedHistorySources } from "../agent/lib/run-runtime"; +import { + assertResearchPurpose, + attribute, + purposeOf, + requireBuilderAttribute, + requireTeamAgentAttribute, +} from "../agent/lib/session-purpose"; + +const context = (purpose?: string, commandType?: string) => ({ + session: { + auth: { + current: purpose + ? { + attributes: { + purpose, + conversationId: "chat-1", + commandType, + }, + } + : { attributes: {} }, + initiator: { attributes: { userId: "user-1" } }, + }, + }, +}); + +describe("custom agent continuation tokens", () => { + it("round-trips a builder conversation through the channel token", () => { + expect(builderIdFromToken(builderToken("chat-1"))).toBe("chat-1"); + expect(builderIdFromToken(`crm:${builderToken("chat-1")}`)).toBe("chat-1"); + }); + + it("round-trips a team agent run without accepting another token kind", () => { + expect(runIdFromToken(runToken("run-1"))).toBe("run-1"); + expect(runIdFromToken(builderToken("chat-1"))).toBeNull(); + }); +}); + +describe("builder delivery messages", () => { + it("delivers a question response without submission wrapper text", () => { + expect( + builderDeliveryMessage("submission-1", { + text: "Use a CRM task instead", + inputResponse: { + requestId: "question-1", + answer: "crm-task", + }, + }), + ).toBe("crm-task"); + }); + + it("delivers persisted attachment bytes with model-visible metadata", () => { + const content = Buffer.from("quarterly plan"); + const message = builderDeliveryMessage( + "submission-2", + { text: "Summarize this file", resources: [], attachments: [] }, + [ + { + name: "plan.txt", + mediaType: "text/plain", + content, + }, + ], + ); + + expect(message).toEqual([ + { + type: "text", + text: "Submission id: submission-2\n\nSummarize this file", + }, + { + type: "file", + data: content, + mediaType: "text/plain", + filename: "plan.txt", + }, + ]); + }); +}); + +describe("session purpose boundaries", () => { + it("reads current-turn attributes before initiator attributes", () => { + expect(attribute(context("builder"), "conversationId")).toBe("chat-1"); + expect(attribute(context("builder"), "userId")).toBe("user-1"); + }); + + it("defaults ordinary CRM sessions to research", () => { + expect(purposeOf(context())).toBe("research"); + expect(() => assertResearchPurpose(context())).not.toThrow(); + }); + + it("rejects research writes from builder and team-agent sessions", () => { + expect(() => assertResearchPurpose(context("builder"))).toThrow(); + expect(() => assertResearchPurpose(context("team-agent"))).toThrow(); + }); + + it("binds specialist tools to their explicit session purpose", () => { + expect( + requireBuilderAttribute( + context("builder", "CREATE_AGENT"), + "conversationId", + ), + ).toBe("chat-1"); + expect(() => + requireBuilderAttribute(context("builder", "CHAT"), "conversationId"), + ).toThrow(); + expect(() => + requireTeamAgentAttribute(context("builder"), "runId"), + ).toThrow(); + }); +}); + +describe("deployed agent data sources", () => { + it("enables only integrations stored in the approved manifest", () => { + expect(allowedHistorySources([])).toEqual({ + gmail: false, + calendar: false, + }); + expect( + allowedHistorySources([ + { kind: "integration", id: "google:gmail", label: "Gmail" }, + ]), + ).toEqual({ gmail: true, calendar: false }); + }); +}); + +describe("builder command routing", () => { + it("delegates only the explicit creation command to the agent builder", () => { + const creation = builderTaskMarkdown("CREATE_AGENT"); + expect(creation).toContain("Call agent_builder exactly once"); + expect(creation).toContain("call ask_question"); + expect(creation).toContain("exactly one decision at a time"); + expect(creation).toContain( + "Do not interrupt a sufficiently specific request", + ); + const chat = builderTaskMarkdown("CHAT"); + expect(chat).toContain("Do not call agent_builder"); + expect(chat).toContain("call ask_question"); + expect(chat).toContain("one focused follow-up"); + expect(chat).toContain( + "Do not restate or enumerate individual deal rows in prose, bullets, or tables", + ); + expect(builderTaskMarkdown(null)).toContain("private CRM assistant chat"); + }); + + it("requires the configured model to title only a new builder chat", () => { + const untitled = builderTaskMarkdown("CHAT", true); + expect(untitled).toContain("call set_chat_title once"); + expect(untitled).toContain("three to seven words"); + expect(builderTaskMarkdown("CHAT", false)).not.toContain("set_chat_title"); + }); +}); diff --git a/apps/agent/test/durable-agent-runtime.integration.spec.ts b/apps/agent/test/durable-agent-runtime.integration.spec.ts new file mode 100644 index 00000000..7f198819 --- /dev/null +++ b/apps/agent/test/durable-agent-runtime.integration.spec.ts @@ -0,0 +1,554 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import type { SendFn } from "eve/channels"; +import audit from "../agent/hooks/audit"; +import { + builderToken, + dispatchAgentRun, + dispatchBuilderSubmission, + failRun, + pendingAgentRunIds, + pendingBuilderSubmissionIds, + queueDueAgentRuns, +} from "../agent/lib/custom-agent-dispatch"; +import { createRunActivity, finishRun } from "../agent/lib/run-runtime"; + +const suffix = crypto.randomUUID(); +const userId = `durable-runtime-user-${suffix}`; +const domain = `durable-${suffix}.example.test`; +let agentId = ""; +let versionId = ""; +let companyId = ""; +let otherCompanyId = ""; +let triggerId = ""; +const builderConversationIds: string[] = []; + +beforeAll(async () => { + await db.user.create({ + data: { + id: userId, + name: "Durable Runtime Test", + email: `${userId}@example.test`, + }, + }); + const [company, otherCompany] = await Promise.all([ + db.company.create({ + data: { name: "Durable Runtime Company", domain }, + select: { id: true }, + }), + db.company.create({ + data: { name: "Out of Scope Company", domain: `other-${domain}` }, + select: { id: true }, + }), + ]); + companyId = company.id; + otherCompanyId = otherCompany.id; + + const agent = await db.agentDefinition.create({ + data: { + name: "Durable runtime", + status: "LIVE", + createdById: userId, + }, + select: { id: true }, + }); + agentId = agent.id; + const version = await db.agentVersion.create({ + data: { + agentId, + number: 1, + status: "DEPLOYED", + instructions: "Create one approved CRM activity.", + manifest: { + dataScope: { + mode: "SELECTED", + resources: [ + { + kind: "company", + id: companyId, + label: "Durable Runtime Company", + }, + ], + }, + actions: [{ type: "crm.activity.create", activityTypes: ["NOTE"] }], + }, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + approvedAt: new Date(), + deployedAt: new Date(), + }, + select: { id: true }, + }); + versionId = version.id; + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: versionId }, + }); + const trigger = await db.agentTrigger.create({ + data: { + agentId, + versionId, + type: "SCHEDULE", + name: "Every hour", + config: { intervalMinutes: 60 }, + createdById: userId, + enabled: true, + nextRunAt: new Date(Date.now() - 60_000), + }, + select: { id: true }, + }); + triggerId = trigger.id; +}); + +afterAll(async () => { + if (builderConversationIds.length > 0) { + await db.agentConversation.deleteMany({ + where: { id: { in: builderConversationIds } }, + }); + } + if (agentId) { + await db.agentEvent.deleteMany({ + where: { sessionId: { startsWith: `durable-session-${suffix}` } }, + }); + await db.agentRunEvent.deleteMany({ where: { run: { agentId } } }); + await db.agentAction.deleteMany({ where: { agentId } }); + await db.activity.deleteMany({ + where: { meta: { path: ["agentId"], equals: agentId } }, + }); + await db.agentAuditEvent.deleteMany({ where: { agentId } }); + await db.agentRun.deleteMany({ where: { agentId } }); + await db.agentTrigger.deleteMany({ where: { agentId } }); + await db.agentDefinition.updateMany({ + where: { id: agentId }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ where: { agentId } }); + await db.agentDefinition.deleteMany({ where: { id: agentId } }); + } + await db.company.deleteMany({ + where: { id: { in: [companyId, otherCompanyId] } }, + }); + await db.user.deleteMany({ where: { id: userId } }); +}); + +async function createRun( + status: "QUEUED" | "RUNNING" = "RUNNING", + startedAt: Date | null = new Date(), + sessionId: string | null = null, +) { + return db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "MANUAL", + status, + startedAt, + sessionId, + idempotencyKey: `durable-run-${crypto.randomUUID()}`, + correlationId: crypto.randomUUID(), + events: { create: { sequence: 0, type: "run.queued", data: {} } }, + }, + select: { id: true }, + }); +} + +describe("durable custom-agent runtime", () => { + it("advances a due trigger only when its run is committed", async () => { + const now = new Date(); + const results = await Promise.all( + Array.from({ length: 4 }, () => queueDueAgentRuns(now)), + ); + + expect(results.reduce((total, count) => total + count, 0)).toBe(1); + const [trigger, scheduledRuns] = await Promise.all([ + db.agentTrigger.findUniqueOrThrow({ where: { id: triggerId } }), + db.agentRun.findMany({ + where: { triggerId }, + select: { id: true, status: true, input: true }, + }), + ]); + expect(trigger.lastRunAt).not.toBeNull(); + expect(trigger.nextRunAt?.getTime()).toBeGreaterThan(now.getTime()); + expect(scheduledRuns).toHaveLength(1); + expect(scheduledRuns[0]?.status).toBe("QUEUED"); + }); + + it("recovers only sessionless runs with an expired delivery lease", async () => { + const stale = new Date(Date.now() - 10 * 60_000); + const [recoverable, active] = await Promise.all([ + createRun("RUNNING", stale), + createRun("RUNNING", stale, `durable-session-${suffix}-already-started`), + ]); + + const pending = await pendingAgentRunIds(); + const [recovered, untouched] = await Promise.all([ + db.agentRun.findUniqueOrThrow({ where: { id: recoverable.id } }), + db.agentRun.findUniqueOrThrow({ where: { id: active.id } }), + ]); + expect(pending).toContain(recoverable.id); + expect(recovered).toMatchObject({ status: "QUEUED", startedAt: null }); + expect( + await db.agentRunEvent.count({ + where: { runId: recoverable.id, type: "run.delivery_recovered" }, + }), + ).toBe(1); + expect(untouched.status).toBe("RUNNING"); + }); + + it("claims one live run delivery and persists its Eve session", async () => { + const run = await createRun("QUEUED", null); + let deliveries = 0; + const sessionId = `durable-session-${suffix}-agent-dispatch`; + const send = (async () => { + deliveries += 1; + return { id: sessionId }; + }) as unknown as SendFn; + + const attempts = await Promise.allSettled([ + dispatchAgentRun(run.id, send), + dispatchAgentRun(run.id, send), + ]); + const persisted = await db.agentRun.findUniqueOrThrow({ + where: { id: run.id }, + }); + expect( + attempts.filter((attempt) => attempt.status === "fulfilled"), + ).toHaveLength(1); + expect(deliveries).toBe(1); + expect(persisted).toMatchObject({ + status: "RUNNING", + sessionId, + modelId: "test/model", + }); + }); + + it("restores a builder continuation when a delivery lease expires", async () => { + const conversation = await db.agentConversation.create({ + data: { + kind: "BUILDER", + userId, + sessionId: `durable-session-${suffix}-builder`, + continuationToken: null, + submissions: { + create: { + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { text: "Continue building" }, + status: "SENDING", + attemptCount: 1, + sentAt: new Date(Date.now() - 10 * 60_000), + }, + }, + }, + select: { id: true, submissions: { select: { id: true } } }, + }); + builderConversationIds.push(conversation.id); + + await pendingBuilderSubmissionIds(); + const [submission, restored] = await Promise.all([ + db.agentConversationSubmission.findUniqueOrThrow({ + where: { id: conversation.submissions[0]?.id }, + }), + db.agentConversation.findUniqueOrThrow({ + where: { id: conversation.id }, + }), + ]); + expect(submission.status).toBe("PENDING"); + expect(restored.continuationToken).toBe(builderToken(conversation.id)); + }); + + it("keeps concurrent builder dispatches in conversation order", async () => { + const conversation = await db.agentConversation.create({ + data: { kind: "BUILDER", userId }, + select: { id: true }, + }); + builderConversationIds.push(conversation.id); + const firstCreatedAt = new Date(); + const [first, second] = await Promise.all([ + db.agentConversationSubmission.create({ + data: { + conversationId: conversation.id, + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { text: "First" }, + createdAt: firstCreatedAt, + }, + select: { id: true }, + }), + db.agentConversationSubmission.create({ + data: { + conversationId: conversation.id, + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { text: "Second" }, + createdAt: new Date(firstCreatedAt.getTime() + 1), + }, + select: { id: true }, + }), + ]); + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + let deliveries = 0; + const send = (async () => { + deliveries += 1; + started.resolve(); + await release.promise; + return { id: `durable-session-${suffix}-builder-dispatch` }; + }) as unknown as SendFn; + + const firstDispatch = dispatchBuilderSubmission(first.id, send); + await started.promise; + let secondError: Error | null = null; + try { + await dispatchBuilderSubmission(second.id, send); + } catch (error) { + secondError = error as Error; + } + release.resolve(); + await firstDispatch; + + const submissions = await db.agentConversationSubmission.findMany({ + where: { id: { in: [first.id, second.id] } }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { id: true, status: true }, + }); + expect(deliveries).toBe(1); + expect(secondError?.message).toContain( + "already claimed or is out of order", + ); + expect(submissions).toEqual([ + { id: first.id, status: "ACCEPTED" }, + { id: second.id, status: "PENDING" }, + ]); + }); + + it("loads persisted attachment bytes into the Eve builder turn", async () => { + const content = Buffer.from("durable attachment"); + const conversation = await db.agentConversation.create({ + data: { + kind: "BUILDER", + userId, + submissions: { + create: { + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { + text: "Read the attachment", + resources: [], + attachments: [ + { + name: "brief.txt", + type: "text/plain", + size: content.byteLength, + }, + ], + }, + attachments: { + create: { + name: "brief.txt", + mediaType: "text/plain", + size: content.byteLength, + content, + }, + }, + }, + }, + }, + select: { id: true, submissions: { select: { id: true } } }, + }); + builderConversationIds.push(conversation.id); + let delivered: unknown; + const send = (async (input: unknown) => { + delivered = input; + return { id: `durable-session-${suffix}-attachment` }; + }) as unknown as SendFn; + + await dispatchBuilderSubmission( + conversation.submissions[0]?.id ?? "", + send, + ); + const parts = Array.isArray(delivered) ? delivered : []; + expect(parts).toHaveLength(2); + expect(parts[1]).toMatchObject({ + type: "file", + mediaType: "text/plain", + filename: "brief.txt", + }); + expect(Buffer.from(recordOf(parts[1]).data as Uint8Array)).toEqual(content); + }); + + it("ingests a replayed Eve event and its usage exactly once", async () => { + const run = await createRun(); + const eventId = `evt_${suffix}_usage`; + type AuditHandler = ( + event: { + type: string; + data: object; + meta: { id: string; at: string }; + }, + ctx: { + session: { + id: string; + auth: { + current: { attributes: Record }; + initiator: null; + }; + }; + }, + ) => Promise; + const handler = audit.events["*"] as unknown as AuditHandler; + const event = { + type: "step.completed", + data: { usage: { inputTokens: 5, outputTokens: 3, costUsd: 0.01 } }, + meta: { id: eventId, at: new Date().toISOString() }, + }; + const context = { + session: { + id: `durable-session-${suffix}-usage`, + auth: { + current: { + attributes: { purpose: "team-agent", runId: run.id }, + }, + initiator: null, + }, + }, + }; + + await handler(event, context); + await handler(event, context); + + const persisted = await db.agentRun.findUniqueOrThrow({ + where: { id: run.id }, + }); + expect(persisted).toMatchObject({ + inputTokens: 5, + outputTokens: 3, + nextEventSequence: 1, + }); + expect(Number(persisted.costUsd)).toBe(0.01); + expect(await db.agentRunEvent.count({ where: { id: eventId } })).toBe(1); + expect(await db.agentEvent.count({ where: { id: eventId } })).toBe(1); + }); + + it("lets the first terminal state win without duplicate terminal logs", async () => { + const [completed, failed] = await Promise.all([createRun(), createRun()]); + await finishRun(completed.id, { summary: "Completed safely" }); + await failRun(completed.id, "LATE_FAILURE", "This arrived late"); + await failRun(failed.id, "FIRST_FAILURE", "Failed safely"); + let lateCompletionError: Error | null = null; + try { + await finishRun(failed.id, { + summary: "This must not overwrite failure", + }); + } catch (error) { + lateCompletionError = error as Error; + } + await failRun(failed.id, "SECOND_FAILURE", "This arrived late"); + + const [completedRow, failedRow] = await Promise.all([ + db.agentRun.findUniqueOrThrow({ where: { id: completed.id } }), + db.agentRun.findUniqueOrThrow({ where: { id: failed.id } }), + ]); + expect(completedRow).toMatchObject({ + status: "SUCCEEDED", + summary: "Completed safely", + }); + expect(failedRow).toMatchObject({ + status: "FAILED", + errorCode: "FIRST_FAILURE", + errorMessage: "Failed safely", + }); + expect(lateCompletionError?.message).toContain("already ended with FAILED"); + expect( + await db.agentRunEvent.count({ + where: { + runId: { in: [completed.id, failed.id] }, + type: { in: ["run.completed", "run.failed"] }, + }, + }), + ).toBe(2); + }); + + it("claims an approved CRM action once and rejects scope before target access", async () => { + const run = await createRun(); + const input = { + type: "NOTE" as const, + targetKind: "company" as const, + targetId: companyId, + subject: "Durable note", + body: "Created exactly once.", + }; + const attempts = await Promise.allSettled( + Array.from({ length: 4 }, () => + createRunActivity(run.id, "shared-call", input), + ), + ); + expect( + attempts.filter((attempt) => attempt.status === "fulfilled").length, + ).toBeGreaterThanOrEqual(1); + const replay = await createRunActivity(run.id, "shared-call", input); + expect(replay.replayed).toBe(true); + let reusedCallError: Error | null = null; + try { + await createRunActivity(run.id, "shared-call", { + ...input, + body: "Different input must not replay the earlier action.", + }); + } catch (error) { + reusedCallError = error as Error; + } + expect(reusedCallError?.message).toContain("already used for other input"); + expect( + await db.agentAction.count({ + where: { runId: run.id, idempotencyKey: `${run.id}:shared-call` }, + }), + ).toBe(1); + expect( + await db.activity.count({ + where: { meta: { path: ["runId"], equals: run.id } }, + }), + ).toBe(1); + + let scopeError: Error | null = null; + try { + await createRunActivity(run.id, "out-of-scope", { + ...input, + targetId: otherCompanyId, + }); + } catch (error) { + scopeError = error as Error; + } + expect(scopeError?.message).toContain("outside this agent version"); + expect( + await db.agentAction.count({ + where: { idempotencyKey: `${run.id}:out-of-scope` }, + }), + ).toBe(0); + + let activityTypeError: Error | null = null; + try { + await createRunActivity(run.id, "unapproved-task", { + ...input, + type: "TASK", + subject: "This type was not approved", + }); + } catch (error) { + activityTypeError = error as Error; + } + expect(activityTypeError?.message).toContain( + "does not allow CRM task activities", + ); + expect( + await db.agentAction.count({ + where: { idempotencyKey: `${run.id}:unapproved-task` }, + }), + ).toBe(0); + }); +}); + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} diff --git a/apps/agent/test/lanes.integration.spec.ts b/apps/agent/test/lanes.integration.spec.ts index 5bd365fe..a2cde1ce 100644 --- a/apps/agent/test/lanes.integration.spec.ts +++ b/apps/agent/test/lanes.integration.spec.ts @@ -4,6 +4,7 @@ import { DIRECT_KINDS, isDirectKind, PRIORITY } from "@crm/db/agent-tasks"; import { claimDue } from "../agent/lib/tasks"; const REASON = "lane-test"; +const TEST_PRIORITY_OFFSET = 1_000_000; const VISIBLE = { only: DIRECT_KINDS } as const; const RESEARCH = { except: DIRECT_KINDS } as const; @@ -21,7 +22,7 @@ async function queue(kind: string, priority: number) { kind, reason: REASON, dueAt: new Date(Date.now() - 1000), - priority, + priority: TEST_PRIORITY_OFFSET + priority, budget: 2, }, select: { id: true }, diff --git a/apps/agent/test/lookup.integration.spec.ts b/apps/agent/test/lookup.integration.spec.ts index 6e441056..c903c565 100644 --- a/apps/agent/test/lookup.integration.spec.ts +++ b/apps/agent/test/lookup.integration.spec.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DealStage, db } from "@crm/db"; -import { searchCrm } from "../agent/lib/lookup"; +import { listDeals, searchCrm } from "../agent/lib/lookup"; const suffix = process.env.TEST_RUN_ID ?? "lookup-spec"; const domain = `northwind-${suffix}.test`; @@ -11,6 +11,8 @@ let brightwaterId: string; let paulaId: string; let peterId: string; let dealId: string; +let freshDealId: string; +let closedDealId: string; beforeAll(async () => { await cleanup(); @@ -21,12 +23,20 @@ beforeAll(async () => { name: "Rep One", email: `rep.${suffix}@example.test`, emailVerified: true, + image: "https://cdn.example.test/rep-one.png", }, select: { id: true }, }); const northwind = await db.company.create({ - data: { name: `Northwind ${suffix}`, domain }, + data: { + name: `Northwind ${suffix}`, + domain, + iconUrl: "https://cdn.example.test/northwind-icon.png", + iconDarkUrl: "https://cdn.example.test/northwind-icon-dark.png", + iconTone: "opaque", + logoUrl: "https://cdn.example.test/northwind-logo.svg", + }, select: { id: true }, }); northwindId = northwind.id; @@ -69,10 +79,35 @@ beforeAll(async () => { ownerId: user.id, stage: DealStage.QUALIFIED_TO_BUY, amount: 12_000, + lastActivityAt: new Date("2026-06-01T12:00:00.000Z"), }, select: { id: true }, }); dealId = deal.id; + + const freshDeal = await db.deal.create({ + data: { + name: `Fresh expansion ${suffix}`, + companyId: northwindId, + ownerId: user.id, + stage: DealStage.CONTRACT_SENT, + lastActivityAt: new Date("2026-08-04T12:00:00.000Z"), + }, + select: { id: true }, + }); + freshDealId = freshDeal.id; + + const closedDeal = await db.deal.create({ + data: { + name: `Closed renewal ${suffix}`, + companyId: brightwaterId, + ownerId: user.id, + stage: DealStage.CLOSED_LOST, + lastActivityAt: new Date("2026-05-01T12:00:00.000Z"), + }, + select: { id: true }, + }); + closedDealId = closedDeal.id; }); afterAll(cleanup); @@ -164,3 +199,43 @@ describe("searchCrm", () => { expect((await searchCrm("a")).total).toBe(0); }); }); + +describe("listDeals", () => { + it("lists stale open deals across the pipeline", async () => { + const result = await listDeals({ + status: "open", + inactiveForDays: 14, + now: new Date("2026-08-05T12:00:00.000Z"), + }); + const ids = result.deals.map((deal) => deal.id); + + expect(ids).toContain(dealId); + expect(ids).not.toContain(freshDealId); + expect(ids).not.toContain(closedDealId); + expect(result.deals.find((deal) => deal.id === dealId)).toMatchObject({ + daysSinceLastActivity: 65, + neverActive: false, + company: { + domain, + iconUrl: "https://cdn.example.test/northwind-icon.png", + iconDarkUrl: "https://cdn.example.test/northwind-icon-dark.png", + iconTone: "opaque", + logoUrl: "https://cdn.example.test/northwind-logo.svg", + }, + owner: { image: "https://cdn.example.test/rep-one.png" }, + }); + }); + + it("paginates a broad deal sweep without repeating a row", async () => { + const first = await listDeals({ status: "all", limit: 1 }); + expect(first.hasMore).toBe(true); + expect(first.nextCursor).toBeTruthy(); + + const second = await listDeals({ + status: "all", + limit: 1, + cursor: first.nextCursor ?? undefined, + }); + expect(second.deals[0]?.id).not.toBe(first.deals[0]?.id); + }); +}); diff --git a/apps/agent/test/verify-key.spec.ts b/apps/agent/test/verify-key.spec.ts index d942622f..ab01df88 100644 --- a/apps/agent/test/verify-key.spec.ts +++ b/apps/agent/test/verify-key.spec.ts @@ -31,6 +31,20 @@ describe("checking a Context key", () => { expect(classifyKey(answered(403, "FORBIDDEN")).outcome).toBe("valid"); }); + it("accepts an exhausted free-tier key when Context answers with 401", () => { + expect(classifyKey(answered(401, "USAGE_EXCEEDED")).outcome).toBe("valid"); + expect( + classifyKey( + new APIError( + 401, + { message: "This account has no API credits remaining." }, + undefined, + new Headers(), + ), + ).outcome, + ).toBe("valid"); + }); + it("accepts a key when Context itself is having a bad day", () => { for (const status of [429, 500, 502, 503]) { expect(classifyKey(answered(status)).outcome).toBe("valid"); diff --git a/apps/agent/turbo.json b/apps/agent/turbo.json index 0f8bc0bd..04a2ddde 100644 --- a/apps/agent/turbo.json +++ b/apps/agent/turbo.json @@ -4,13 +4,15 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": [".eve/**", ".output/**", ".vercel/output/**"] + "outputs": [".output/**", ".vercel/output/**"] }, "check-types": { "dependsOn": ["topo", "^build"] }, "dev": { + "dependsOn": ["$TURBO_EXTENDS$"], "cache": false, + "interactive": true, "persistent": true, "passThroughEnv": [ "AGENT_BRIDGE_SECRET", diff --git a/apps/api/package.json b/apps/api/package.json index 065a9e5c..9128b2a1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,8 +16,8 @@ "postinstall": "node scripts/chmod-trpc-binary.mjs", "start": "bun src/main.ts", "start:prod": "bun dist/main.js", - "test": "CRM_TELEMETRY_DISABLED=1 bun test", - "test:watch": "bun test --watch", + "test": "CRM_TELEMETRY_DISABLED=1 bun test --preload ./test/setup.ts", + "test:watch": "CRM_TELEMETRY_DISABLED=1 bun test --watch --preload ./test/setup.ts", "trpc:generate": "nestjs-trpc generate -e src/app.module.ts -r \"**/*.router.ts\" -o src/generated", "clean": "rm -rf .turbo dist node_modules src/generated" }, diff --git a/apps/api/src/agent/agent-access.service.ts b/apps/api/src/agent/agent-access.service.ts new file mode 100644 index 00000000..1c2c4284 --- /dev/null +++ b/apps/api/src/agent/agent-access.service.ts @@ -0,0 +1,85 @@ +import { + isWorkspaceAdmin, + isWorkspaceRole, + WORKSPACE_ID, + type WorkspaceRole, +} from "@crm/auth"; +import type { Db } from "@crm/db"; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { canReadAgent, isPrivateAgentDraft } from "./agent-visibility"; + +@Injectable() +export class AgentAccessService { + constructor(@InjectDatabase() private readonly db: Db) {} + + async assertMember(userId: string): Promise { + const member = await this.db.member.findUnique({ + where: { + organizationId_userId: { organizationId: WORKSPACE_ID, userId }, + }, + select: { role: true }, + }); + + if (!member) { + throw new ForbiddenException("You are not a member of this workspace."); + } + + return isWorkspaceRole(member.role) ? member.role : "member"; + } + + async assertCanManage(agentId: string, userId: string) { + const [role, agent] = await Promise.all([ + this.assertMember(userId), + this.db.agentDefinition.findFirst({ + where: { id: agentId, status: { not: "DELETED" } }, + select: { + id: true, + createdById: true, + status: true, + name: true, + description: true, + }, + }), + ]); + + if (!agent) { + throw new NotFoundException(`No agent with id ${agentId}.`); + } + + if (isPrivateAgentDraft(agent.status) && agent.createdById !== userId) { + throw new NotFoundException(`No agent with id ${agentId}.`); + } + + if (agent.createdById !== userId && !isWorkspaceAdmin(role)) { + throw new ForbiddenException( + "Only the creator or a workspace admin can change this agent.", + ); + } + + return agent; + } + + async assertCanRead(agentId: string, userId: string) { + await this.assertMember(userId); + const agent = await this.db.agentDefinition.findFirst({ + where: { id: agentId, status: { not: "DELETED" } }, + select: { + id: true, + createdById: true, + status: true, + currentVersionId: true, + }, + }); + + if (!agent || !canReadAgent(agent.status, agent.createdById, userId)) { + throw new NotFoundException(`No agent with id ${agentId}.`); + } + + return agent; + } +} diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts new file mode 100644 index 00000000..35b77e87 --- /dev/null +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -0,0 +1,487 @@ +import type { Db, Prisma } from "@crm/db"; +import type { AgentDefinitionStatus } from "@crm/db/enums"; +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { AgentAccessService } from "./agent-access.service"; +import { TEAM_AGENT_STATUSES } from "./agent-visibility"; +import type { AgentDeployInput, AgentUpdateInput } from "./agents.contracts"; + +@Injectable() +export class AgentDefinitionsService { + constructor( + @InjectDatabase() private readonly db: Db, + private readonly access: AgentAccessService, + ) {} + + async list(userId: string) { + await this.access.assertMember(userId); + + const rows = await this.db.agentDefinition.findMany({ + where: { status: { in: [...TEAM_AGENT_STATUSES] } }, + orderBy: { updatedAt: "desc" }, + select: { + id: true, + name: true, + description: true, + status: true, + createdAt: true, + updatedAt: true, + createdBy: { select: { id: true, name: true, image: true } }, + currentVersion: { + select: { id: true, number: true, deployedAt: true }, + }, + triggers: { + where: { enabled: true }, + orderBy: { nextRunAt: "asc" }, + take: 1, + select: { id: true, type: true, name: true, nextRunAt: true }, + }, + _count: { select: { runs: true } }, + }, + }); + + return rows.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + currentVersion: row.currentVersion + ? { + ...row.currentVersion, + deployedAt: row.currentVersion.deployedAt?.toISOString() ?? null, + } + : null, + triggers: row.triggers.map((trigger) => ({ + ...trigger, + nextRunAt: trigger.nextRunAt?.toISOString() ?? null, + })), + runCount: row._count.runs, + })); + } + + async byId(id: string, userId: string) { + await this.access.assertCanRead(id, userId); + + const row = await this.db.agentDefinition.findFirst({ + where: { id, status: { not: "DELETED" } }, + select: { + id: true, + name: true, + description: true, + status: true, + createdById: true, + createdAt: true, + updatedAt: true, + createdBy: { select: { id: true, name: true, image: true } }, + currentVersion: { + select: { + id: true, + number: true, + status: true, + manifest: true, + modelId: true, + sandboxPolicy: true, + approvedAt: true, + deployedAt: true, + }, + }, + triggers: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + type: true, + name: true, + config: true, + enabled: true, + nextRunAt: true, + lastRunAt: true, + }, + }, + _count: { select: { runs: true } }, + }, + }); + + if (!row) throw new NotFoundException(`No agent with id ${id}.`); + + return { + ...row, + canManage: row.createdById === userId || (await this.canAdmin(userId)), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + currentVersion: row.currentVersion + ? { + ...row.currentVersion, + approvedAt: row.currentVersion.approvedAt?.toISOString() ?? null, + deployedAt: row.currentVersion.deployedAt?.toISOString() ?? null, + } + : null, + triggers: row.triggers.map((trigger) => ({ + ...trigger, + nextRunAt: trigger.nextRunAt?.toISOString() ?? null, + lastRunAt: trigger.lastRunAt?.toISOString() ?? null, + })), + runCount: row._count.runs, + }; + } + + async update(input: AgentUpdateInput, userId: string) { + await this.access.assertCanManage(input.id, userId); + const description = input.description?.trim() || null; + + const updated = await this.db.$transaction(async (tx) => { + const agent = await this.lockAgent(tx, input.id); + const row = await tx.agentDefinition.update({ + where: { id: input.id }, + data: { name: input.name, description }, + select: { id: true, name: true, description: true, status: true }, + }); + + await tx.agentAuditEvent.create({ + data: { + agentId: input.id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.updated", + summary: "Changed agent details", + before: { name: agent.name, description: agent.description }, + after: { name: input.name, description }, + }, + }); + + return row; + }); + + return updated; + } + + async deploy(input: AgentDeployInput, userId: string) { + await this.access.assertCanManage(input.id, userId); + return this.db.$transaction(async (tx) => { + const agent = await this.lockAgent(tx, input.id); + const existing = await tx.agentAuditEvent.findFirst({ + where: { + agentId: input.id, + type: "agent.deployed", + requestId: input.clientRequestId, + }, + select: { versionId: true }, + }); + + if (existing) { + if (existing.versionId !== input.versionId) { + throw new BadRequestException( + "That deployment request has already been used.", + ); + } + + return { id: input.id, versionId: input.versionId, status: "LIVE" }; + } + + const version = await tx.agentVersion.findFirst({ + where: { id: input.versionId, agentId: input.id }, + select: { id: true, number: true, status: true, manifest: true }, + }); + + if (!version) { + throw new NotFoundException(`No version with id ${input.versionId}.`); + } + + if (version.status !== "READY" && version.status !== "DEPLOYED") { + throw new BadRequestException( + "Only a validated agent version can be deployed.", + ); + } + const metadata = versionMetadata(version.manifest); + + const now = new Date(); + await tx.agentVersion.updateMany({ + where: { + agentId: input.id, + status: "DEPLOYED", + id: { not: input.versionId }, + }, + data: { status: "READY" }, + }); + + await tx.agentVersion.update({ + where: { id: input.versionId }, + data: { + status: "DEPLOYED", + approvedAt: now, + deployedAt: now, + }, + }); + + await tx.agentDefinition.update({ + where: { id: input.id }, + data: { + currentVersionId: input.versionId, + status: "LIVE", + archivedAt: null, + ...metadata, + }, + }); + + await tx.agentTrigger.updateMany({ + where: { agentId: input.id }, + data: { enabled: false }, + }); + + await tx.agentTrigger.updateMany({ + where: { agentId: input.id, versionId: input.versionId }, + data: { enabled: true }, + }); + + await tx.agentAuditEvent.create({ + data: { + agentId: input.id, + versionId: input.versionId, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.deployed", + summary: `Made version ${version.number} live for the team`, + before: { status: agent.status }, + after: { status: "LIVE", version: version.number, ...metadata }, + requestId: input.clientRequestId, + }, + }); + + return { id: input.id, versionId: input.versionId, status: "LIVE" }; + }); + } + + async pause(id: string, userId: string) { + return this.changeStatus( + id, + userId, + ["LIVE"], + "PAUSED", + "agent.paused", + "Paused agent", + "Only a live agent can be paused.", + ); + } + + async resume(id: string, userId: string) { + return this.changeStatus( + id, + userId, + ["PAUSED"], + "LIVE", + "agent.resumed", + "Resumed agent", + "Only a paused agent can be resumed.", + ); + } + + async archive(id: string, userId: string) { + return this.changeStatus( + id, + userId, + ["LIVE", "PAUSED"], + "ARCHIVED", + "agent.archived", + "Archived agent", + "Only a live or paused agent can be archived.", + { archivedAt: new Date() }, + ); + } + + async restore(id: string, userId: string) { + return this.changeStatus( + id, + userId, + ["ARCHIVED"], + "PAUSED", + "agent.restored", + "Restored agent", + "Only an archived agent can be restored.", + { archivedAt: null }, + ); + } + + async remove(id: string, userId: string) { + await this.access.assertCanManage(id, userId); + const now = new Date(); + + return this.db.$transaction(async (tx) => { + const [current] = await tx.$queryRaw< + Array<{ id: string; status: string }> + >` + SELECT id, status + FROM "agentDefinition" + WHERE id = ${id} + FOR UPDATE + `; + + if (!current || current.status === "DELETED") { + throw new NotFoundException(`No agent with id ${id}.`); + } + + const disabledTriggers = await tx.agentTrigger.updateMany({ + where: { agentId: id, enabled: true }, + data: { enabled: false, nextRunAt: null }, + }); + + const cancellableRuns = await tx.$queryRaw>` + SELECT id + FROM "agentRun" + WHERE "agentId" = ${id} + AND ( + status IN ('QUEUED', 'WAITING_FOR_APPROVAL') + OR (status = 'RUNNING' AND "sessionId" IS NULL) + ) + ORDER BY id + FOR UPDATE + `; + + for (const run of cancellableRuns) { + const cancelled = await tx.agentRun.update({ + where: { id: run.id }, + data: { + status: "CANCELLED", + finishedAt: now, + errorCode: "AGENT_DELETED", + errorMessage: "The agent was deleted before this run completed.", + nextEventSequence: { increment: 1 }, + }, + select: { nextEventSequence: true }, + }); + + await tx.agentRunEvent.create({ + data: { + runId: run.id, + sequence: cancelled.nextEventSequence, + type: "run.cancelled", + data: { reason: "agent.deleted" }, + emittedAt: now, + }, + }); + } + + const agent = await tx.agentDefinition.update({ + where: { id }, + data: { status: "DELETED", deletedAt: now }, + select: { id: true, name: true, status: true, updatedAt: true }, + }); + + await tx.agentAuditEvent.create({ + data: { + agentId: id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.deleted", + summary: "Deleted agent", + before: { status: current.status }, + after: { + status: "DELETED", + disabledTriggers: disabledTriggers.count, + cancelledRuns: cancellableRuns.length, + }, + }, + }); + + return { + ...agent, + updatedAt: agent.updatedAt.toISOString(), + disabledTriggers: disabledTriggers.count, + cancelledRuns: cancellableRuns.length, + }; + }); + } + + private async changeStatus( + id: string, + userId: string, + allowedFrom: readonly AgentDefinitionStatus[], + status: "LIVE" | "PAUSED" | "ARCHIVED", + type: string, + summary: string, + invalidStatusMessage: string, + extra: Prisma.AgentDefinitionUpdateInput = {}, + ) { + await this.access.assertCanManage(id, userId); + + return this.db.$transaction(async (tx) => { + const before = await this.lockAgent(tx, id); + if (!allowedFrom.includes(before.status)) { + throw new BadRequestException(invalidStatusMessage); + } + + const agent = await tx.agentDefinition.update({ + where: { id }, + data: { status, ...extra }, + select: { id: true, name: true, status: true, updatedAt: true }, + }); + + await tx.agentAuditEvent.create({ + data: { + agentId: id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type, + summary, + before: { status: before.status }, + after: { status }, + }, + }); + + return { ...agent, updatedAt: agent.updatedAt.toISOString() }; + }); + } + + private async lockAgent(tx: Prisma.TransactionClient, id: string) { + const [agent] = await tx.$queryRaw< + Array<{ + id: string; + status: AgentDefinitionStatus; + name: string; + description: string | null; + }> + >` + SELECT id, status, name, description + FROM "agentDefinition" + WHERE id = ${id} + FOR UPDATE + `; + + if (!agent || agent.status === "DELETED") { + throw new NotFoundException(`No agent with id ${id}.`); + } + + return agent; + } + + private async canAdmin(userId: string): Promise { + const role = await this.access.assertMember(userId); + return role === "owner" || role === "admin"; + } +} + +function versionMetadata(manifest: unknown): { + name?: string; + description?: string | null; +} { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + return {}; + } + + const record = manifest as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = + typeof record.description === "string" + ? record.description.trim() || null + : undefined; + + return { + ...(name ? { name } : {}), + ...(description !== undefined ? { description } : {}), + }; +} diff --git a/apps/api/src/agent/agent-runs.service.ts b/apps/api/src/agent/agent-runs.service.ts new file mode 100644 index 00000000..7585c801 --- /dev/null +++ b/apps/api/src/agent/agent-runs.service.ts @@ -0,0 +1,217 @@ +import { randomUUID } from "node:crypto"; +import type { Db } from "@crm/db"; +import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { AgentAccessService } from "./agent-access.service"; +import { AgentTriggerService } from "./agent-trigger.service"; +import type { AgentRunNowInput } from "./agents.contracts"; + +@Injectable() +export class AgentRunsService { + constructor( + @InjectDatabase() private readonly db: Db, + private readonly access: AgentAccessService, + private readonly trigger: AgentTriggerService, + ) {} + + async list(agentId: string, limit: number, userId: string) { + await this.readableAgent(agentId, userId); + + const rows = await this.db.agentRun.findMany({ + where: { agentId }, + orderBy: { createdAt: "desc" }, + take: limit, + select: { + id: true, + status: true, + triggerType: true, + summary: true, + modelId: true, + inputTokens: true, + outputTokens: true, + costUsd: true, + errorCode: true, + errorMessage: true, + createdAt: true, + startedAt: true, + finishedAt: true, + initiatedBy: { select: { id: true, name: true, image: true } }, + version: { select: { id: true, number: true } }, + events: { + orderBy: { sequence: "asc" }, + select: { + id: true, + sequence: true, + type: true, + data: true, + emittedAt: true, + }, + }, + actions: { + orderBy: { plannedAt: "asc" }, + select: { + id: true, + type: true, + provider: true, + targetType: true, + targetId: true, + targetLabel: true, + summary: true, + status: true, + externalId: true, + attemptCount: true, + errorCode: true, + errorMessage: true, + plannedAt: true, + startedAt: true, + completedAt: true, + }, + }, + }, + }); + + return rows.map((run) => ({ + ...run, + costUsd: run.costUsd?.toString() ?? null, + createdAt: run.createdAt.toISOString(), + startedAt: run.startedAt?.toISOString() ?? null, + finishedAt: run.finishedAt?.toISOString() ?? null, + events: run.events.map((event) => ({ + ...event, + emittedAt: event.emittedAt.toISOString(), + })), + actions: run.actions.map((action) => ({ + ...action, + plannedAt: action.plannedAt.toISOString(), + startedAt: action.startedAt?.toISOString() ?? null, + completedAt: action.completedAt?.toISOString() ?? null, + })), + })); + } + + async activity(agentId: string, limit: number, userId: string) { + await this.readableAgent(agentId, userId); + + const rows = await this.db.agentAuditEvent.findMany({ + where: { agentId }, + orderBy: { emittedAt: "desc" }, + take: limit, + select: { + id: true, + type: true, + summary: true, + before: true, + after: true, + requestId: true, + emittedAt: true, + actorType: true, + actorId: true, + actorUser: { select: { id: true, name: true, image: true } }, + version: { select: { id: true, number: true } }, + }, + }); + + return rows.map((event) => ({ + ...event, + emittedAt: event.emittedAt.toISOString(), + })); + } + + async runNow(input: AgentRunNowInput, userId: string) { + await this.access.assertMember(userId); + const existing = await this.db.agentRun.findUnique({ + where: { idempotencyKey: input.clientRequestId }, + select: { id: true, agentId: true }, + }); + + if (existing) { + this.assertReplayMatches(existing.agentId, input.id); + this.trigger.deployedAgentRunQueued(); + return { id: existing.id }; + } + + const run = await this.db.$transaction(async (tx) => { + await lockIdempotencyKey(tx, input.clientRequestId); + const replay = await tx.agentRun.findUnique({ + where: { idempotencyKey: input.clientRequestId }, + select: { id: true, agentId: true }, + }); + if (replay) { + this.assertReplayMatches(replay.agentId, input.id); + return { id: replay.id }; + } + + const [agent] = await tx.$queryRaw< + Array<{ + id: string; + status: string; + currentVersionId: string | null; + }> + >` + SELECT id, status, "currentVersionId" + FROM "agentDefinition" + WHERE id = ${input.id} + FOR UPDATE + `; + + if (!agent || agent.status === "DELETED") { + throw new NotFoundException(`No agent with id ${input.id}.`); + } + + if (agent.status !== "LIVE" || !agent.currentVersionId) { + throw new BadRequestException("This agent is not live yet."); + } + + const created = await tx.agentRun.create({ + data: { + agentId: input.id, + versionId: agent.currentVersionId, + initiatedById: userId, + triggerType: "MANUAL", + idempotencyKey: input.clientRequestId, + correlationId: randomUUID(), + events: { + create: { sequence: 0, type: "run.queued", data: {} }, + }, + }, + select: { id: true }, + }); + + await tx.agentAuditEvent.create({ + data: { + agentId: input.id, + versionId: agent.currentVersionId, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "run.requested", + summary: "Requested a manual run", + requestId: input.clientRequestId, + }, + }); + + return created; + }); + + this.trigger.deployedAgentRunQueued(); + return run; + } + + private async readableAgent(agentId: string, userId: string) { + return this.access.assertCanRead(agentId, userId); + } + + private assertReplayMatches( + existingAgentId: string, + requestedAgentId: string, + ) { + if (existingAgentId !== requestedAgentId) { + throw new BadRequestException("That run request has already been used."); + } + } +} diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index e82b6d23..a22500cc 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -80,6 +80,14 @@ export class AgentTriggerService { }); } + builderConversationQueued(): void { + this.pokeRoute("/internal/crm/builder-dispatch"); + } + + deployedAgentRunQueued(): void { + this.pokeRoute("/internal/crm/agent-dispatch"); + } + async backfill(input: { kind: string; reason: string; @@ -193,6 +201,10 @@ export class AgentTriggerService { } private poke(): void { + this.pokeRoute("/internal/crm/dispatch"); + } + + private pokeRoute(path: string): void { const agent = bridge(); if (!agent) return; @@ -204,7 +216,7 @@ export class AgentTriggerService { }; try { - void fetch(agent.url("/internal/crm/dispatch"), { + void fetch(agent.url(path), { method: "POST", headers: { authorization: `Bearer ${agent.secret}` }, signal: AbortSignal.timeout(POKE_TIMEOUT_MS), diff --git a/apps/api/src/agent/agent-visibility.ts b/apps/api/src/agent/agent-visibility.ts new file mode 100644 index 00000000..c74082c5 --- /dev/null +++ b/apps/api/src/agent/agent-visibility.ts @@ -0,0 +1,15 @@ +import type { AgentDefinitionStatus } from "@crm/db/enums"; + +export const TEAM_AGENT_STATUSES = ["LIVE", "PAUSED", "ARCHIVED"] as const; + +export function isPrivateAgentDraft(status: AgentDefinitionStatus): boolean { + return status === "DRAFT" || status === "DEPLOYING"; +} + +export function canReadAgent( + status: AgentDefinitionStatus, + createdById: string, + userId: string, +): boolean { + return !isPrivateAgentDraft(status) || createdById === userId; +} diff --git a/apps/api/src/agent/agent.module.ts b/apps/api/src/agent/agent.module.ts index d88c9d59..2cdae2c2 100644 --- a/apps/api/src/agent/agent.module.ts +++ b/apps/api/src/agent/agent.module.ts @@ -1,10 +1,24 @@ import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { AgentAccessService } from "./agent-access.service"; +import { AgentDefinitionsService } from "./agent-definitions.service"; import { AgentQueueService } from "./agent-queue.service"; +import { AgentRunsService } from "./agent-runs.service"; import { AgentTriggerService } from "./agent-trigger.service"; +import { AgentsRouter } from "./agents.router"; import { ResearchKeyService } from "./research-key.service"; @Module({ - providers: [AgentTriggerService, AgentQueueService, ResearchKeyService], + imports: [TrpcModule], + providers: [ + AgentAccessService, + AgentDefinitionsService, + AgentQueueService, + AgentRunsService, + AgentTriggerService, + AgentsRouter, + ResearchKeyService, + ], exports: [AgentTriggerService, AgentQueueService, ResearchKeyService], }) export class AgentModule {} diff --git a/apps/api/src/agent/agents.contracts.ts b/apps/api/src/agent/agents.contracts.ts new file mode 100644 index 00000000..967e3046 --- /dev/null +++ b/apps/api/src/agent/agents.contracts.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const agentIdInput = z.object({ id: z.string().min(1) }); + +export const agentHistoryInput = agentIdInput.extend({ + limit: z.number().int().min(1).max(100).default(50), +}); + +export const agentUpdateInput = agentIdInput.extend({ + name: z.string().trim().min(1).max(120), + description: z.string().trim().max(500).nullable(), +}); + +export type AgentUpdateInput = z.infer; + +export const agentRunNowInput = agentIdInput.extend({ + clientRequestId: z.uuid(), +}); + +export type AgentRunNowInput = z.infer; + +export const agentDeployInput = agentIdInput.extend({ + versionId: z.string().min(1), + clientRequestId: z.uuid(), +}); + +export type AgentDeployInput = z.infer; diff --git a/apps/api/src/agent/agents.router.ts b/apps/api/src/agent/agents.router.ts new file mode 100644 index 00000000..0dec15a8 --- /dev/null +++ b/apps/api/src/agent/agents.router.ts @@ -0,0 +1,107 @@ +import { Inject } from "@nestjs/common"; +import { + Ctx, + Input, + Mutation, + Query, + Router, + UseMiddlewares, +} from "nestjs-trpc"; +import type { z } from "zod"; +import type { AuthedTrpcContext } from "../trpc/context.types"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { AgentDefinitionsService } from "./agent-definitions.service"; +import { AgentRunsService } from "./agent-runs.service"; +import { + agentDeployInput, + agentHistoryInput, + agentIdInput, + agentRunNowInput, + agentUpdateInput, +} from "./agents.contracts"; + +@Router({ alias: "agents" }) +@UseMiddlewares(AuthMiddleware) +export class AgentsRouter { + constructor( + @Inject(AgentDefinitionsService) + private readonly agents: AgentDefinitionsService, + @Inject(AgentRunsService) + private readonly runs: AgentRunsService, + ) {} + + @Query() + async list(@Ctx() ctx: AuthedTrpcContext) { + return this.agents.list(ctx.user.id); + } + + @Query({ input: agentIdInput }) + async byId(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.agents.byId(id, ctx.user.id); + } + + @Query({ input: agentHistoryInput }) + async history( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.runs.list(input.id, input.limit, ctx.user.id); + } + + @Query({ input: agentHistoryInput }) + async activity( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.runs.activity(input.id, input.limit, ctx.user.id); + } + + @Mutation({ input: agentUpdateInput }) + async update( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.agents.update(input, ctx.user.id); + } + + @Mutation({ input: agentDeployInput }) + async deploy( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.agents.deploy(input, ctx.user.id); + } + + @Mutation({ input: agentIdInput }) + async pause(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.agents.pause(id, ctx.user.id); + } + + @Mutation({ input: agentIdInput }) + async resume(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.agents.resume(id, ctx.user.id); + } + + @Mutation({ input: agentIdInput }) + async archive(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.agents.archive(id, ctx.user.id); + } + + @Mutation({ input: agentIdInput }) + async restore(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.agents.restore(id, ctx.user.id); + } + + @Mutation({ input: agentIdInput }) + async remove(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.agents.remove(id, ctx.user.id); + } + + @Mutation({ input: agentRunNowInput }) + async runNow( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.runs.runNow(input, ctx.user.id); + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 4b7b31d7..0f0e4956 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,6 +3,7 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { AuthModule as BetterAuthModule } from "@thallesp/nestjs-better-auth"; import { ActivitiesModule } from "./activities/activities.module"; +import { AgentModule } from "./agent/agent.module"; import { AuthModule } from "./auth/auth.module"; import { BackfillModule } from "./backfill/backfill.module"; import { AppCacheModule } from "./cache/cache.module"; @@ -49,6 +50,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; CurrencyModule, DealsModule, ActivitiesModule, + AgentModule, DashboardModule, SearchModule, GoogleModule, diff --git a/apps/api/src/conversations/conversation-attachments.controller.ts b/apps/api/src/conversations/conversation-attachments.controller.ts new file mode 100644 index 00000000..693bdd20 --- /dev/null +++ b/apps/api/src/conversations/conversation-attachments.controller.ts @@ -0,0 +1,56 @@ +import type { auth } from "@crm/auth"; +import { + Controller, + Get, + Param, + Query, + Res, + StreamableFile, +} from "@nestjs/common"; +import { Session, type UserSession } from "@thallesp/nestjs-better-auth"; +import type { Response } from "express"; +import { ConversationsService } from "./conversations.service"; + +type CrmSession = UserSession; + +@Controller("api/conversations/attachments") +export class ConversationAttachmentsController { + constructor(private readonly conversations: ConversationsService) {} + + @Get(":id") + async read( + @Param("id") id: string, + @Query("share") shareToken: string | undefined, + @Session() session: CrmSession, + @Res({ passthrough: true }) response: Response, + ) { + const attachment = await this.conversations.attachment( + id, + session.user.id, + shareToken, + ); + const content = Buffer.from(attachment.content); + const disposition = attachment.previewable ? "inline" : "attachment"; + const mediaType = attachment.previewable + ? attachment.mediaType + : "application/octet-stream"; + + response.setHeader("Cache-Control", "private, max-age=31536000, immutable"); + response.setHeader("Content-Length", content.byteLength.toString()); + response.setHeader("Content-Type", mediaType); + response.setHeader( + "Content-Disposition", + `${disposition}; filename*=UTF-8''${encodeHeaderValue(attachment.name)}`, + ); + response.setHeader("X-Content-Type-Options", "nosniff"); + + return new StreamableFile(content); + } +} + +function encodeHeaderValue(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} diff --git a/apps/api/src/conversations/conversation-attachments.ts b/apps/api/src/conversations/conversation-attachments.ts new file mode 100644 index 00000000..4dc8d4fa --- /dev/null +++ b/apps/api/src/conversations/conversation-attachments.ts @@ -0,0 +1,45 @@ +import type { Prisma } from "@crm/db"; + +export type StoredBuilderAttachment = { + id: string; + name: string; + mediaType: string; + size: number; +}; + +export function builderMessageWithAttachments( + value: Prisma.JsonValue, + attachments: StoredBuilderAttachment[], + shareToken?: string, +): Prisma.JsonObject { + const message = recordOf(value); + return { + ...message, + attachments: attachments.map((attachment) => ({ + id: attachment.id, + name: attachment.name, + type: attachment.mediaType, + size: attachment.size, + previewUrl: isPreviewableImage(attachment.mediaType) + ? attachmentUrl(attachment.id, shareToken) + : null, + })), + } as Prisma.JsonObject; +} + +export function isPreviewableImage(mediaType: string): boolean { + return ["image/gif", "image/jpeg", "image/png", "image/webp"].includes( + mediaType.toLowerCase(), + ); +} + +function attachmentUrl(id: string, shareToken?: string): string { + const path = `/api/conversations/attachments/${encodeURIComponent(id)}`; + return shareToken ? `${path}?share=${encodeURIComponent(shareToken)}` : path; +} + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} diff --git a/apps/api/src/conversations/conversation-share-token.ts b/apps/api/src/conversations/conversation-share-token.ts new file mode 100644 index 00000000..025564e8 --- /dev/null +++ b/apps/api/src/conversations/conversation-share-token.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto"; + +export function conversationShareTokenHash(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} diff --git a/apps/api/src/conversations/conversation-sharing.service.ts b/apps/api/src/conversations/conversation-sharing.service.ts new file mode 100644 index 00000000..852fac05 --- /dev/null +++ b/apps/api/src/conversations/conversation-sharing.service.ts @@ -0,0 +1,234 @@ +import { randomBytes } from "node:crypto"; +import { WORKSPACE_ID } from "@crm/auth"; +import type { Db, Prisma } from "@crm/db"; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { builderMessageWithAttachments } from "./conversation-attachments"; +import { conversationShareTokenHash } from "./conversation-share-token"; + +@Injectable() +export class ConversationSharingService { + constructor(@InjectDatabase() private readonly db: Db) {} + + async status(conversationId: string, userId: string) { + await this.ownedBuilder(conversationId, userId); + + const share = await this.db.agentConversationShare.findFirst({ + where: { + conversationId, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + select: { createdAt: true, expiresAt: true }, + }); + + return { + enabled: share !== null, + createdAt: share?.createdAt.toISOString() ?? null, + expiresAt: share?.expiresAt?.toISOString() ?? null, + }; + } + + async create(conversationId: string, userId: string) { + const token = randomBytes(32).toString("base64url"); + const tokenHash = conversationShareTokenHash(token); + + const created = await this.db.$transaction(async (tx) => { + if (!(await this.lockOwnedBuilder(tx, conversationId, userId))) { + return false; + } + + await tx.agentConversationShare.updateMany({ + where: { conversationId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + + await tx.agentConversationShare.create({ + data: { conversationId, createdById: userId, tokenHash }, + }); + + return true; + }); + if (!created) this.missingBuilder(conversationId); + + return { token }; + } + + async revoke(conversationId: string, userId: string) { + const revoked = await this.db.$transaction(async (tx) => { + if (!(await this.lockOwnedBuilder(tx, conversationId, userId))) { + return false; + } + + await tx.agentConversationShare.updateMany({ + where: { conversationId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + + return true; + }); + if (!revoked) this.missingBuilder(conversationId); + + return { id: conversationId }; + } + + async resolve(token: string, userId: string) { + await this.assertWorkspaceMember(userId); + + const share = await this.db.agentConversationShare.findFirst({ + where: { + tokenHash: conversationShareTokenHash(token), + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + conversation: { kind: "BUILDER" }, + }, + select: { + conversation: { + select: { + id: true, + title: true, + sessionId: true, + lastMessageAt: true, + user: { select: { name: true } }, + agent: { select: { id: true, name: true, status: true } }, + builderArtifacts: { + orderBy: [{ createdAt: "desc" }, { revision: "desc" }], + take: 100, + select: { + id: true, + versionId: true, + path: true, + language: true, + content: true, + previousContent: true, + revision: true, + status: true, + createdAt: true, + }, + }, + submissions: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + commandType: true, + message: true, + status: true, + errorMessage: true, + createdAt: true, + attachments: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + id: true, + name: true, + mediaType: true, + size: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!share) { + throw new NotFoundException("That shared conversation is unavailable."); + } + + const { conversation } = share; + const events = conversation.sessionId + ? await this.db.agentEvent.findMany({ + where: { sessionId: conversation.sessionId }, + orderBy: [{ emittedAt: "desc" }, { id: "desc" }], + take: 5000, + select: { id: true, type: true, data: true, emittedAt: true }, + }) + : []; + + return { + id: conversation.id, + title: conversation.title, + ownerName: conversation.user.name, + lastMessageAt: conversation.lastMessageAt.toISOString(), + agent: conversation.agent, + builderArtifacts: conversation.builderArtifacts.map((artifact) => ({ + ...artifact, + createdAt: artifact.createdAt.toISOString(), + })), + submissions: conversation.submissions.map( + ({ attachments, ...submission }) => ({ + ...submission, + message: builderMessageWithAttachments( + submission.message, + attachments, + token, + ), + createdAt: submission.createdAt.toISOString(), + }), + ), + events: events.reverse().map((event) => ({ + type: event.type, + data: event.data, + meta: { id: event.id, at: event.emittedAt.toISOString() }, + })), + }; + } + + private async ownedBuilder(conversationId: string, userId: string) { + const conversation = await this.db.agentConversation.findFirst({ + where: { id: conversationId, userId, kind: "BUILDER" }, + select: { id: true }, + }); + + if (!conversation) { + this.missingBuilder(conversationId); + } + + return conversation; + } + + private async lockOwnedBuilder( + tx: Prisma.TransactionClient, + conversationId: string, + userId: string, + ): Promise { + const rows = await tx.$queryRaw>` + SELECT id + FROM "agentConversation" + WHERE id = ${conversationId} + FOR UPDATE + `; + if (rows.length === 0) return false; + + return ( + (await tx.agentConversation.count({ + where: { id: conversationId, userId, kind: "BUILDER" }, + })) === 1 + ); + } + + private missingBuilder(conversationId: string): never { + throw new NotFoundException( + `No builder conversation with id ${conversationId}.`, + ); + } + + private async assertWorkspaceMember(userId: string): Promise { + const member = await this.db.member.findUnique({ + where: { + organizationId_userId: { organizationId: WORKSPACE_ID, userId }, + }, + select: { id: true }, + }); + + if (!member) { + throw new ForbiddenException( + "This conversation belongs to another team.", + ); + } + } +} diff --git a/apps/api/src/conversations/conversations.contracts.ts b/apps/api/src/conversations/conversations.contracts.ts index 5b99ae34..1e8e9ad0 100644 --- a/apps/api/src/conversations/conversations.contracts.ts +++ b/apps/api/src/conversations/conversations.contracts.ts @@ -1,23 +1,36 @@ import { z } from "zod"; -export const conversationListInput = z.object({ - contactId: z.string().optional(), - companyId: z.string().optional(), - dealId: z.string().optional(), -}); +const recordShape = { + contactId: z.string().trim().min(1).optional(), + companyId: z.string().trim().min(1).optional(), + dealId: z.string().trim().min(1).optional(), +}; + +const hasExactlyOneRecord = (input: { + contactId?: string; + companyId?: string; + dealId?: string; +}) => + [input.contactId, input.companyId, input.dealId].filter(Boolean).length === 1; + +const recordMessage = "Choose exactly one contact, company or deal."; + +export const conversationListInput = z + .object(recordShape) + .refine(hasExactlyOneRecord, { message: recordMessage }); export type ConversationListInput = z.infer; -export const conversationSaveInput = z.object({ - contactId: z.string().optional(), - companyId: z.string().optional(), - dealId: z.string().optional(), - sessionId: z.string(), - continuationToken: z.string().nullish(), - streamIndex: z.number().int().min(0).optional(), - title: z.string().optional(), - messageCount: z.number().int().min(0).optional(), -}); +export const conversationSaveInput = z + .object({ + ...recordShape, + sessionId: z.string().trim().min(1), + continuationToken: z.string().nullish(), + streamIndex: z.number().int().min(0).optional(), + title: z.string().trim().max(120).optional(), + messageCount: z.number().int().min(0).optional(), + }) + .refine(hasExactlyOneRecord, { message: recordMessage }); export type ConversationSaveInput = z.infer; @@ -29,3 +42,103 @@ export const conversationEventsInput = z.object({ }); export type ConversationEventsInput = z.infer; + +export const builderResource = z.object({ + kind: z.enum(["integration", "company", "contact", "deal"]), + id: z.string().trim().min(1).max(160), + label: z.string().trim().min(1).max(120), + detail: z.string().trim().max(160).nullable().optional(), + imageUrl: z.url().nullable().optional(), +}); + +export const builderAttachment = z + .object({ + name: z.string().trim().min(1).max(180), + type: z.string().trim().min(1).max(120), + size: z.number().int().min(1).max(2_000_000), + contentBase64: z + .string() + .min(1) + .max(2_800_000) + .regex( + /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/, + "Attachment content must be valid base64.", + ), + }) + .refine( + (attachment) => + decodedBase64Size(attachment.contentBase64) === attachment.size, + { message: "Attachment size does not match its content.", path: ["size"] }, + ); + +const builderStoredAttachment = z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1).max(180), + type: z.string().trim().min(1).max(120), + size: z.number().int().min(1).max(2_000_000), + previewUrl: z.string().nullable().optional(), +}); + +const builderPromptShape = { + clientRequestId: z.uuid(), + commandType: z.enum(["CHAT", "CREATE_AGENT"]).default("CHAT"), + message: z.string().trim().min(1).max(20_000), + resources: z.array(builderResource).max(20).default([]), +}; + +export const builderConversationCreateInput = z.object({ + ...builderPromptShape, + attachments: z.array(builderAttachment).max(5).default([]), +}); + +export type BuilderConversationCreateInput = z.infer< + typeof builderConversationCreateInput +>; + +export const builderConversationSubmitInput = z.object({ + ...builderPromptShape, + id: z.string().min(1), + attachments: z + .array(z.union([builderAttachment, builderStoredAttachment])) + .max(5) + .default([]), +}); + +export type BuilderConversationSubmitInput = z.infer< + typeof builderConversationSubmitInput +>; + +export const builderQuestionResponseInput = z + .object({ + id: z.string().min(1), + clientRequestId: z.uuid(), + requestId: z.string().trim().min(1).max(240), + optionId: z.string().trim().min(1).max(160).optional(), + text: z.string().trim().min(1).max(20_000).optional(), + }) + .refine((input) => Boolean(input.optionId) !== Boolean(input.text), { + message: "Choose one option or enter a written answer.", + }); + +export type BuilderQuestionResponseInput = z.infer< + typeof builderQuestionResponseInput +>; + +export const sharedConversationInput = z.object({ + token: z.string().trim().min(32).max(256), +}); + +export const builderResourceSearchInput = z.object({ + q: z.string().trim().max(120).default(""), +}); + +export const builderResponseRatingInput = z.object({ + id: z.string().min(1), + messageId: z.string().trim().min(1).max(240), + rating: z.enum(["UP", "DOWN"]).nullable(), +}); + +function decodedBase64Size(value: string): number { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + return (value.length / 4) * 3 - padding; +} diff --git a/apps/api/src/conversations/conversations.module.ts b/apps/api/src/conversations/conversations.module.ts index 54df59fb..7aaba500 100644 --- a/apps/api/src/conversations/conversations.module.ts +++ b/apps/api/src/conversations/conversations.module.ts @@ -1,11 +1,19 @@ import { Module } from "@nestjs/common"; +import { AgentModule } from "../agent/agent.module"; import { TrpcModule } from "../trpc/trpc.module"; +import { ConversationAttachmentsController } from "./conversation-attachments.controller"; +import { ConversationSharingService } from "./conversation-sharing.service"; import { ConversationsRouter } from "./conversations.router"; import { ConversationsService } from "./conversations.service"; @Module({ - imports: [TrpcModule], - providers: [ConversationsService, ConversationsRouter], + imports: [TrpcModule, AgentModule], + controllers: [ConversationAttachmentsController], + providers: [ + ConversationsService, + ConversationSharingService, + ConversationsRouter, + ], exports: [ConversationsService], }) export class ConversationsModule {} diff --git a/apps/api/src/conversations/conversations.router.ts b/apps/api/src/conversations/conversations.router.ts index ac66ba8b..e65987ee 100644 --- a/apps/api/src/conversations/conversations.router.ts +++ b/apps/api/src/conversations/conversations.router.ts @@ -10,11 +10,18 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { ConversationSharingService } from "./conversation-sharing.service"; import { + builderConversationCreateInput, + builderConversationSubmitInput, + builderQuestionResponseInput, + builderResourceSearchInput, + builderResponseRatingInput, conversationEventsInput, conversationIdInput, conversationListInput, conversationSaveInput, + sharedConversationInput, } from "./conversations.contracts"; import { ConversationsService } from "./conversations.service"; @@ -24,6 +31,8 @@ export class ConversationsRouter { constructor( @Inject(ConversationsService) private readonly conversations: ConversationsService, + @Inject(ConversationSharingService) + private readonly sharing: ConversationSharingService, ) {} @Query({ input: conversationListInput }) @@ -34,6 +43,21 @@ export class ConversationsRouter { return this.conversations.list(input, ctx.user.id); } + @Query() + async builderList(@Ctx() ctx: AuthedTrpcContext) { + return this.conversations.listBuilder(ctx.user.id); + } + + @Query({ input: builderResourceSearchInput }) + async builderResources(@Ctx() ctx: AuthedTrpcContext, @Input("q") q: string) { + return this.conversations.builderResources(q, ctx.user.id); + } + + @Query({ input: conversationIdInput }) + async builderById(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.conversations.builderById(id, ctx.user.id); + } + @Query({ input: conversationEventsInput }) async events( @Ctx() ctx: AuthedTrpcContext, @@ -50,6 +74,63 @@ export class ConversationsRouter { return this.conversations.save(input, ctx.user.id); } + @Mutation({ input: builderConversationCreateInput }) + async createBuilder( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.conversations.createBuilder(input, ctx.user.id); + } + + @Mutation({ input: builderConversationSubmitInput }) + async submitBuilder( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.conversations.submitBuilder(input, ctx.user.id); + } + + @Mutation({ input: builderQuestionResponseInput }) + async answerBuilderQuestion( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.conversations.answerBuilderQuestion(input, ctx.user.id); + } + + @Mutation({ input: builderResponseRatingInput }) + async rateBuilderResponse( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.conversations.rateBuilderResponse(input, ctx.user.id); + } + + @Mutation({ input: conversationIdInput }) + async markRead(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.conversations.markRead(id, ctx.user.id); + } + + @Query({ input: conversationIdInput }) + async shareStatus(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.sharing.status(id, ctx.user.id); + } + + @Mutation({ input: conversationIdInput }) + async createShare(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.sharing.create(id, ctx.user.id); + } + + @Mutation({ input: conversationIdInput }) + async revokeShare(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.sharing.revoke(id, ctx.user.id); + } + + @Query({ input: sharedConversationInput }) + async shared(@Ctx() ctx: AuthedTrpcContext, @Input("token") token: string) { + return this.sharing.resolve(token, ctx.user.id); + } + @Mutation({ input: conversationIdInput }) async remove(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.conversations.remove(id, ctx.user.id); diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index c28b7786..5de7ae2d 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -1,15 +1,23 @@ -import type { Db } from "@crm/db"; -import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { WORKSPACE_ID } from "@crm/auth"; +import { type Db, type Prisma, Prisma as PrismaNamespace } from "@crm/db"; import { BadRequestException, - Inject, Injectable, Logger, NotFoundException, + Optional, } from "@nestjs/common"; -import type { Cache } from "cache-manager"; +import { AgentTriggerService } from "../agent/agent-trigger.service"; import { InjectDatabase } from "../database/database.constants"; +import { + builderMessageWithAttachments, + isPreviewableImage, +} from "./conversation-attachments"; +import { conversationShareTokenHash } from "./conversation-share-token"; import type { + BuilderConversationCreateInput, + BuilderConversationSubmitInput, + BuilderQuestionResponseInput, ConversationEventsInput, ConversationListInput, ConversationSaveInput, @@ -25,10 +33,30 @@ export interface ConversationSummary { lastMessageAt: string; } -const LIST_TTL_MS = 10 * 60_000; +export interface BuilderConversationSummary { + id: string; + sessionId: string | null; + continuationToken: string | null; + streamIndex: number; + title: string | null; + messageCount: number; + lastMessageAt: string; + lastAssistantAt: string | null; + unread: boolean; + state: "working" | "unread" | "deployed" | "idle"; + agent: { + id: string; + name: string; + status: string; + } | null; +} -const listKey = (userId: string, recordId: string) => - `agent:conversations:${userId}:${recordId}`; +type ExistingBuilderRequest = { + id: string; + conversationId: string; + submittedById: string; + conversation: { id: string; userId: string; kind: string }; +}; @Injectable() export class ConversationsService { @@ -36,7 +64,7 @@ export class ConversationsService { constructor( @InjectDatabase() private readonly db: Db, - @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Optional() private readonly agent?: AgentTriggerService, ) {} async list( @@ -44,12 +72,7 @@ export class ConversationsService { userId: string, ): Promise { const recordId = this.recordId(input); - const key = listKey(userId, recordId); - - const cached = await this.cache.get(key); - if (cached) return cached; - - this.logger.debug({ message: "Conversation list cache miss", recordId }); + this.logger.debug({ message: "Conversation list read", recordId }); const rows = await this.db.agentConversation.findMany({ where: { @@ -71,53 +94,763 @@ export class ConversationsService { }, }); - const summaries = rows.map((row) => ({ + const summaries = rows.flatMap((row) => + row.sessionId + ? [ + { + ...row, + sessionId: row.sessionId, + lastMessageAt: row.lastMessageAt.toISOString(), + }, + ] + : [], + ); + + return summaries; + } + + async listBuilder(userId: string): Promise { + const rows = await this.db.agentConversation.findMany({ + where: { userId, kind: "BUILDER" }, + orderBy: { lastMessageAt: "desc" }, + take: 50, + select: { + id: true, + sessionId: true, + continuationToken: true, + streamIndex: true, + title: true, + messageCount: true, + lastMessageAt: true, + lastAssistantAt: true, + lastReadAt: true, + agent: { select: { id: true, name: true, status: true } }, + _count: { + select: { + submissions: { where: { commandType: "CREATE_AGENT" } }, + }, + }, + submissions: { + where: { status: { in: ["PENDING", "SENDING"] } }, + select: { id: true }, + take: 1, + }, + }, + }); + + return rows.map((row) => { + const unread = Boolean( + row.lastAssistantAt && + (!row.lastReadAt || row.lastAssistantAt > row.lastReadAt), + ); + const working = + row.submissions.length > 0 || + Boolean(row.sessionId && !row.continuationToken); + + return { + id: row.id, + sessionId: row.sessionId, + continuationToken: row.continuationToken, + streamIndex: row.streamIndex, + title: row.title, + messageCount: row.messageCount, + lastMessageAt: row.lastMessageAt.toISOString(), + lastAssistantAt: row.lastAssistantAt?.toISOString() ?? null, + unread, + state: + row._count.submissions > 0 && row.agent?.status === "LIVE" + ? "deployed" + : working + ? "working" + : unread + ? "unread" + : "idle", + agent: row.agent, + }; + }); + } + + async builderResources(q: string, userId: string) { + await this.assertWorkspaceMember(userId); + + const search = q.trim(); + const contains = search + ? { contains: search, mode: "insensitive" as const } + : undefined; + + const [companies, contacts, deals] = await Promise.all([ + this.db.company.findMany({ + where: contains ? { name: contains } : undefined, + orderBy: { lastActivityAt: { sort: "desc", nulls: "last" } }, + take: 6, + select: { id: true, name: true, domain: true, logoUrl: true }, + }), + this.db.contact.findMany({ + where: contains + ? { + OR: [ + { firstName: contains }, + { lastName: contains }, + { email: contains }, + ], + } + : undefined, + orderBy: { lastActivityAt: { sort: "desc", nulls: "last" } }, + take: 6, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + imageUrl: true, + company: { select: { name: true } }, + }, + }), + this.db.deal.findMany({ + where: contains ? { name: contains } : undefined, + orderBy: { lastActivityAt: { sort: "desc", nulls: "last" } }, + take: 6, + select: { + id: true, + name: true, + company: { select: { name: true, logoUrl: true } }, + }, + }), + ]); + + return [ + ...companies.map((company) => ({ + kind: "company" as const, + id: company.id, + label: company.name, + detail: company.domain, + imageUrl: company.logoUrl, + })), + ...contacts.map((contact) => ({ + kind: "contact" as const, + id: contact.id, + label: [contact.firstName, contact.lastName].filter(Boolean).join(" "), + detail: contact.company?.name ?? contact.email, + imageUrl: contact.imageUrl, + })), + ...deals.map((deal) => ({ + kind: "deal" as const, + id: deal.id, + label: deal.name, + detail: deal.company.name, + imageUrl: deal.company.logoUrl, + })), + ]; + } + + async builderById(id: string, userId: string) { + const row = await this.db.agentConversation.findFirst({ + where: { id, userId, kind: "BUILDER" }, + select: { + id: true, + sessionId: true, + continuationToken: true, + streamIndex: true, + title: true, + messageCount: true, + lastMessageAt: true, + lastAssistantAt: true, + lastReadAt: true, + agent: { + select: { + id: true, + name: true, + description: true, + status: true, + createdBy: { select: { id: true, name: true } }, + currentVersion: { + select: { + id: true, + number: true, + status: true, + manifest: true, + modelId: true, + sandboxPolicy: true, + deployedAt: true, + }, + }, + triggers: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + type: true, + name: true, + config: true, + enabled: true, + nextRunAt: true, + }, + }, + }, + }, + createdVersions: { + orderBy: { number: "desc" }, + take: 1, + select: { + id: true, + number: true, + status: true, + instructions: true, + manifest: true, + modelId: true, + sandboxPolicy: true, + validation: true, + createdAt: true, + }, + }, + builderArtifacts: { + orderBy: [{ createdAt: "desc" }, { revision: "desc" }], + take: 100, + select: { + id: true, + versionId: true, + path: true, + language: true, + content: true, + previousContent: true, + revision: true, + status: true, + createdAt: true, + }, + }, + feedback: { + where: { userId }, + select: { messageId: true, rating: true }, + }, + submissions: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + clientRequestId: true, + commandType: true, + message: true, + status: true, + errorCode: true, + errorMessage: true, + createdAt: true, + sentAt: true, + acceptedAt: true, + attachments: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + id: true, + name: true, + mediaType: true, + size: true, + }, + }, + }, + }, + }, + }); + + if (!row) { + throw new NotFoundException(`No builder conversation with id ${id}.`); + } + + return { ...row, lastMessageAt: row.lastMessageAt.toISOString(), - })); + lastAssistantAt: row.lastAssistantAt?.toISOString() ?? null, + lastReadAt: row.lastReadAt?.toISOString() ?? null, + agent: row.agent + ? { + ...row.agent, + currentVersion: row.agent.currentVersion + ? { + ...row.agent.currentVersion, + deployedAt: + row.agent.currentVersion.deployedAt?.toISOString() ?? null, + } + : null, + triggers: row.agent.triggers.map((trigger) => ({ + ...trigger, + nextRunAt: trigger.nextRunAt?.toISOString() ?? null, + })), + } + : null, + createdVersions: row.createdVersions.map((version) => ({ + ...version, + createdAt: version.createdAt.toISOString(), + })), + builderArtifacts: row.builderArtifacts.map((artifact) => ({ + ...artifact, + createdAt: artifact.createdAt.toISOString(), + })), + submissions: row.submissions.map(({ attachments, ...submission }) => ({ + ...submission, + message: builderMessageWithAttachments(submission.message, attachments), + createdAt: submission.createdAt.toISOString(), + sentAt: submission.sentAt?.toISOString() ?? null, + acceptedAt: submission.acceptedAt?.toISOString() ?? null, + })), + }; + } - await this.cache.set(key, summaries, LIST_TTL_MS); + async createBuilder( + input: BuilderConversationCreateInput, + userId: string, + ): Promise<{ id: string }> { + const existing = await this.requestByClientId(input.clientRequestId); - return summaries; + if (existing) { + return this.replayBuilderCreation(existing, userId); + } + + const now = new Date(); + try { + const conversation = await this.db.agentConversation.create({ + data: { + kind: "BUILDER", + userId, + title: null, + lastReadAt: now, + lastMessageAt: now, + submissions: { + create: { + submittedById: userId, + clientRequestId: input.clientRequestId, + commandType: input.commandType, + message: this.builderMessage(input), + attachments: { + create: this.attachmentWrites(input.attachments), + }, + }, + }, + }, + select: { id: true }, + }); + + this.agent?.builderConversationQueued(); + return conversation; + } catch (error) { + if (!isUniqueConstraint(error)) throw error; + const winner = await this.requestByClientId(input.clientRequestId); + if (!winner) throw error; + return this.replayBuilderCreation(winner, userId); + } } - async save( - input: ConversationSaveInput, + async submitBuilder( + input: BuilderConversationSubmitInput, userId: string, ): Promise<{ id: string }> { - const recordId = this.recordId(input); + const existing = await this.requestByClientId(input.clientRequestId); - const conversation = await this.db.agentConversation.upsert({ - where: { sessionId: input.sessionId }, - create: { - sessionId: input.sessionId, - continuationToken: input.continuationToken ?? null, - streamIndex: input.streamIndex ?? 0, - title: input.title?.slice(0, 120) ?? null, - messageCount: input.messageCount ?? 0, + if (existing) { + return this.replayBuilderSubmission(existing, input.id, userId); + } + + const conversation = await this.db.agentConversation.findFirst({ + where: { id: input.id, userId, kind: "BUILDER" }, + select: { id: true }, + }); + + if (!conversation) { + throw new NotFoundException( + `No builder conversation with id ${input.id}.`, + ); + } + + try { + const attachmentWrites = await this.submissionAttachmentWrites( + input, userId, - contactId: input.contactId ?? null, - companyId: input.companyId ?? null, - dealId: input.dealId ?? null, - }, - update: { - continuationToken: input.continuationToken ?? null, - streamIndex: input.streamIndex ?? 0, - messageCount: input.messageCount ?? 0, - lastMessageAt: new Date(), + ); + const submission = await this.db.$transaction(async (tx) => { + const created = await tx.agentConversationSubmission.create({ + data: { + conversationId: input.id, + submittedById: userId, + clientRequestId: input.clientRequestId, + commandType: input.commandType, + message: this.builderMessage(input), + attachments: { + create: attachmentWrites, + }, + }, + select: { id: true }, + }); + + await tx.agentConversation.update({ + where: { id: input.id }, + data: { lastMessageAt: new Date(), lastReadAt: new Date() }, + }); + + return created; + }); + + this.agent?.builderConversationQueued(); + return submission; + } catch (error) { + if (!isUniqueConstraint(error)) throw error; + const winner = await this.requestByClientId(input.clientRequestId); + if (!winner) throw error; + return this.replayBuilderSubmission(winner, input.id, userId); + } + } + + async answerBuilderQuestion( + input: BuilderQuestionResponseInput, + userId: string, + ): Promise<{ id: string }> { + const existing = await this.requestByClientId(input.clientRequestId); + + if (existing) { + return this.replayBuilderSubmission(existing, input.id, userId); + } + + const conversation = await this.db.agentConversation.findFirst({ + where: { id: input.id, userId, kind: "BUILDER" }, + select: { id: true, sessionId: true, continuationToken: true }, + }); + + if (!conversation) { + throw new NotFoundException( + `No builder conversation with id ${input.id}.`, + ); + } + + if (!conversation.sessionId || !conversation.continuationToken) { + throw new BadRequestException( + "The agent is no longer waiting for that answer.", + ); + } + + const boundary = await this.db.agentEvent.findFirst({ + where: { + sessionId: conversation.sessionId, + type: { + in: [ + "input.requested", + "message.received", + "turn.cancelled", + "session.completed", + "session.failed", + ], + }, }, - select: { id: true, userId: true }, + orderBy: [{ emittedAt: "desc" }, { id: "desc" }], + select: { type: true, data: true }, }); - if (conversation.userId !== userId) { + if (boundary?.type !== "input.requested") { throw new BadRequestException( - "That conversation belongs to someone else.", + "The agent is no longer waiting for that answer.", ); } - await this.cache.del(listKey(userId, recordId)); + const requests = arrayOf(recordOf(boundary.data).requests).map(recordOf); + const question = requests.find( + (request) => + request.kind === "question" && request.requestId === input.requestId, + ); + + if (!question) { + throw new BadRequestException( + "That follow-up question is no longer active.", + ); + } + + const options = arrayOf(question.options).map(recordOf); + const selected = input.optionId + ? options.find((option) => option.id === input.optionId) + : null; + + if (input.optionId && !selected) { + throw new BadRequestException( + "That answer is not available for this question.", + ); + } + + const acceptsText = + question.allowFreeform === true || + question.display === "text" || + options.length === 0; + if (input.text && !acceptsText) { + throw new BadRequestException( + "Choose one of the available answers for this question.", + ); + } + + const answer = input.optionId ?? input.text; + if (!answer) { + throw new BadRequestException("Choose an answer before submitting."); + } + + const displayText = + typeof selected?.label === "string" ? selected.label : answer; + try { + const submission = await this.db.$transaction(async (tx) => { + const created = await tx.agentConversationSubmission.create({ + data: { + conversationId: input.id, + submittedById: userId, + clientRequestId: input.clientRequestId, + inputRequestId: input.requestId, + commandType: "CHAT", + message: { + text: displayText, + resources: [], + attachments: [], + inputResponse: { requestId: input.requestId, answer }, + }, + }, + select: { id: true }, + }); + + await tx.agentConversation.update({ + where: { id: input.id }, + data: { lastMessageAt: new Date(), lastReadAt: new Date() }, + }); + + return created; + }); - return { id: conversation.id }; + this.agent?.builderConversationQueued(); + return submission; + } catch (error) { + if (!isUniqueConstraint(error)) throw error; + + const winner = await this.requestByClientId(input.clientRequestId); + if (winner) { + return this.replayBuilderSubmission(winner, input.id, userId); + } + + const answered = await this.db.agentConversationSubmission.findFirst({ + where: { + conversationId: input.id, + inputRequestId: input.requestId, + }, + select: { id: true }, + }); + if (answered) { + throw new BadRequestException( + "That follow-up question has already been answered.", + ); + } + + throw error; + } + } + + async markRead(id: string, userId: string): Promise<{ id: string }> { + const updated = await this.db.agentConversation.updateMany({ + where: { id, userId, kind: "BUILDER" }, + data: { lastReadAt: new Date() }, + }); + + if (updated.count === 0) { + throw new NotFoundException(`No builder conversation with id ${id}.`); + } + + return { id }; + } + + async attachment( + id: string, + userId: string, + shareToken?: string, + ): Promise<{ + name: string; + mediaType: string; + content: Uint8Array; + previewable: boolean; + }> { + await this.assertWorkspaceMember(userId); + const share = shareToken?.trim(); + const row = await this.db.agentConversationAttachment.findFirst({ + where: { + id, + submission: { + conversation: { + kind: "BUILDER", + OR: [ + { userId }, + ...(share + ? [ + { + shares: { + some: { + tokenHash: conversationShareTokenHash(share), + revokedAt: null, + OR: [ + { expiresAt: null }, + { expiresAt: { gt: new Date() } }, + ], + }, + }, + }, + ] + : []), + ], + }, + }, + }, + select: { name: true, mediaType: true, content: true }, + }); + + if (!row) { + throw new NotFoundException("That attachment is unavailable."); + } + + return { + ...row, + previewable: isPreviewableImage(row.mediaType), + }; + } + + async rateBuilderResponse( + input: { id: string; messageId: string; rating: "UP" | "DOWN" | null }, + userId: string, + ) { + const conversation = await this.db.agentConversation.findFirst({ + where: { id: input.id, userId, kind: "BUILDER" }, + select: { id: true }, + }); + + if (!conversation) { + throw new NotFoundException( + `No builder conversation with id ${input.id}.`, + ); + } + + const key = { + conversationId_userId_messageId: { + conversationId: input.id, + userId, + messageId: input.messageId, + }, + }; + + if (!input.rating) { + await this.db.agentConversationFeedback.deleteMany({ + where: key.conversationId_userId_messageId, + }); + return { id: input.messageId, rating: null }; + } + + await this.db.agentConversationFeedback.upsert({ + where: key, + create: { + conversationId: input.id, + userId, + messageId: input.messageId, + rating: input.rating, + }, + update: { rating: input.rating }, + }); + + return { id: input.messageId, rating: input.rating }; + } + + async save( + input: ConversationSaveInput, + userId: string, + ): Promise<{ id: string }> { + const recordId = this.recordId(input); + const updateExisting = async (existing: { + id: string; + kind: string; + userId: string; + contactId: string | null; + companyId: string | null; + dealId: string | null; + }) => { + if (existing.userId !== userId || existing.kind !== "RECORD") { + throw new NotFoundException( + `No record conversation with session ${input.sessionId}.`, + ); + } + + const existingRecordId = + existing.contactId ?? existing.companyId ?? existing.dealId; + if (existingRecordId !== recordId) { + throw new BadRequestException( + "A conversation cannot be moved to another CRM record.", + ); + } + + const updated = await this.db.agentConversation.updateMany({ + where: { + id: existing.id, + kind: "RECORD", + userId, + contactId: input.contactId ?? null, + companyId: input.companyId ?? null, + dealId: input.dealId ?? null, + }, + data: { + continuationToken: input.continuationToken ?? null, + streamIndex: input.streamIndex ?? 0, + messageCount: input.messageCount ?? 0, + lastMessageAt: new Date(), + }, + }); + + if (updated.count !== 1) { + throw new NotFoundException( + `No record conversation with session ${input.sessionId}.`, + ); + } + + return { id: existing.id }; + }; + + const existing = await this.db.agentConversation.findUnique({ + where: { sessionId: input.sessionId }, + select: { + id: true, + kind: true, + userId: true, + contactId: true, + companyId: true, + dealId: true, + }, + }); + let conversation: { id: string }; + + if (existing) { + conversation = await updateExisting(existing); + } else { + try { + conversation = await this.db.agentConversation.create({ + data: { + sessionId: input.sessionId, + continuationToken: input.continuationToken ?? null, + streamIndex: input.streamIndex ?? 0, + title: input.title?.slice(0, 120) ?? null, + messageCount: input.messageCount ?? 0, + userId, + contactId: input.contactId ?? null, + companyId: input.companyId ?? null, + dealId: input.dealId ?? null, + }, + select: { id: true }, + }); + } catch (error) { + if (!isUniqueConstraint(error)) throw error; + const winner = await this.db.agentConversation.findUnique({ + where: { sessionId: input.sessionId }, + select: { + id: true, + kind: true, + userId: true, + contactId: true, + companyId: true, + dealId: true, + }, + }); + if (!winner) throw error; + conversation = await updateExisting(winner); + } + } + + return conversation; } async events(input: ConversationEventsInput, userId: string) { @@ -130,14 +863,16 @@ export class ConversationsService { throw new NotFoundException(`No conversation with id ${input.id}.`); } + if (!conversation.sessionId) return []; + const events = await this.db.agentEvent.findMany({ where: { sessionId: conversation.sessionId }, - orderBy: { emittedAt: "asc" }, + orderBy: [{ emittedAt: "desc" }, { id: "desc" }], take: input.limit, select: { id: true, type: true, data: true, emittedAt: true }, }); - return events.map((event) => ({ + return events.reverse().map((event) => ({ type: event.type, data: event.data, meta: { id: event.id, at: event.emittedAt.toISOString() }, @@ -150,9 +885,6 @@ export class ConversationsService { select: { id: true, userId: true, - contactId: true, - companyId: true, - dealId: true, sessionId: true, }, }); @@ -161,22 +893,15 @@ export class ConversationsService { throw new NotFoundException(`No conversation with id ${id}.`); } - await this.db.$transaction([ - this.db.agentEvent.deleteMany({ - where: { sessionId: conversation.sessionId }, - }), - this.db.agentConversation.delete({ where: { id } }), - ]); + await this.db.$transaction(async (tx) => { + if (conversation.sessionId) { + await tx.agentEvent.deleteMany({ + where: { sessionId: conversation.sessionId }, + }); + } - await this.cache.del( - listKey( - userId, - conversation.contactId ?? - conversation.companyId ?? - conversation.dealId ?? - "", - ), - ); + await tx.agentConversation.delete({ where: { id } }); + }); this.logger.log({ message: "Conversation removed", conversationId: id }); @@ -188,14 +913,177 @@ export class ConversationsService { companyId?: string; dealId?: string; }): string { - const recordId = input.contactId ?? input.companyId ?? input.dealId; + const recordIds = [input.contactId, input.companyId, input.dealId].filter( + (recordId): recordId is string => Boolean(recordId), + ); + const [recordId] = recordIds; - if (!recordId) { + if (!recordId || recordIds.length !== 1) { throw new BadRequestException( - "A conversation belongs to a contact, a company or a deal.", + "Choose exactly one contact, company or deal.", ); } return recordId; } + + private builderMessage(input: { + message: string; + resources: BuilderConversationCreateInput["resources"]; + attachments: + | BuilderConversationCreateInput["attachments"] + | BuilderConversationSubmitInput["attachments"]; + }): Prisma.InputJsonValue { + return { + text: input.message, + resources: input.resources, + attachments: input.attachments.map(({ name, type, size }) => ({ + name, + type, + size, + })), + }; + } + + private attachmentWrites( + attachments: BuilderConversationCreateInput["attachments"], + ) { + return attachments.map((attachment) => ({ + name: attachment.name, + mediaType: attachment.type, + size: attachment.size, + content: Buffer.from(attachment.contentBase64, "base64"), + })); + } + + private async submissionAttachmentWrites( + input: BuilderConversationSubmitInput, + userId: string, + ) { + const referencedIds = input.attachments.flatMap((attachment) => + "contentBase64" in attachment ? [] : [attachment.id], + ); + const referenced = + referencedIds.length > 0 + ? await this.db.agentConversationAttachment.findMany({ + where: { + id: { in: referencedIds }, + submission: { + conversation: { id: input.id, userId, kind: "BUILDER" }, + }, + }, + select: { + id: true, + name: true, + mediaType: true, + size: true, + content: true, + }, + }) + : []; + const referencedById = new Map(referenced.map((row) => [row.id, row])); + + if (referencedIds.some((id) => !referencedById.has(id))) { + throw new BadRequestException( + "One or more attachments are no longer available.", + ); + } + + return input.attachments.map((attachment) => { + if ("contentBase64" in attachment) { + return { + name: attachment.name, + mediaType: attachment.type, + size: attachment.size, + content: Buffer.from(attachment.contentBase64, "base64"), + }; + } + + const stored = referencedById.get(attachment.id); + if (!stored) { + throw new BadRequestException( + "One or more attachments are no longer available.", + ); + } + return { + name: stored.name, + mediaType: stored.mediaType, + size: stored.size, + content: stored.content, + }; + }); + } + + private async assertWorkspaceMember(userId: string): Promise { + const member = await this.db.member.findUnique({ + where: { + organizationId_userId: { organizationId: WORKSPACE_ID, userId }, + }, + select: { id: true }, + }); + + if (!member) { + throw new NotFoundException("No workspace membership was found."); + } + } + + private requestByClientId( + clientRequestId: string, + ): Promise { + return this.db.agentConversationSubmission.findUnique({ + where: { clientRequestId }, + select: { + id: true, + conversationId: true, + submittedById: true, + conversation: { select: { id: true, userId: true, kind: true } }, + }, + }); + } + + private replayBuilderCreation( + existing: ExistingBuilderRequest, + userId: string, + ): { id: string } { + if ( + existing.conversation.userId !== userId || + existing.conversation.kind !== "BUILDER" + ) { + throw new BadRequestException("That request has already been used."); + } + + return { id: existing.conversation.id }; + } + + private replayBuilderSubmission( + existing: ExistingBuilderRequest, + conversationId: string, + userId: string, + ): { id: string } { + if ( + existing.conversationId !== conversationId || + existing.submittedById !== userId + ) { + throw new BadRequestException("That request has already been used."); + } + + return { id: existing.id }; + } +} + +function isUniqueConstraint(error: unknown): boolean { + return ( + error instanceof PrismaNamespace.PrismaClientKnownRequestError && + error.code === "P2002" + ); +} + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function arrayOf(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; } diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index 69becaec..20c90d73 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -14,9 +14,10 @@ import { z } from "zod"; const t = initTRPC.create(); const publicProcedure = t.procedure; import { timelineInput, timelineCountsInput, myTasksInput, activityCreateInput, completeInput } from "../activities/activities.contracts"; +import { agentIdInput, agentHistoryInput, agentUpdateInput, agentDeployInput, agentRunNowInput } from "../agent/agents.contracts"; import { companyListInput, companyIdInput, companyOptionsInput, companyCreateInput, companyUpdateArgs, setPrimaryContactInput } from "../companies/companies.contracts"; import { contactListInput, contactIdInput, contactCreateInput, contactUpdateArgs, factDecisionInput } from "../contacts/contacts.contracts"; -import { conversationListInput, conversationEventsInput, conversationSaveInput, conversationIdInput } from "../conversations/conversations.contracts"; +import { conversationListInput, builderResourceSearchInput, conversationIdInput, conversationEventsInput, conversationSaveInput, builderConversationCreateInput, builderConversationSubmitInput, builderQuestionResponseInput, builderResponseRatingInput, sharedConversationInput } from "../conversations/conversations.contracts"; import { setReportingCurrencyInput, setManualRateInput, removeManualRateInput } from "../currency/currency.contracts"; import { dashboardSummaryInput } from "../dashboard/dashboard.contracts"; import { dealListInput, dealIdInput, dealCreateInput, dealUpdateArgs, setStageInput } from "../deals/deals.contracts"; @@ -25,6 +26,7 @@ import { setAgentModelInput, setResearchKeyInput } from "../settings/settings.co import { ssoProviderListInput, registerSsoProviderInput, deleteSsoProviderInput } from "../sso/sso.contracts"; import { memberListInput, updateWorkspaceInput, setMemberRoleInput } from "../workspace/workspace.contracts"; import type { ActivitiesRouter } from "../activities/activities.router"; +import type { AgentsRouter } from "../agent/agents.router"; import type { CompaniesRouter } from "../companies/companies.router"; import type { ContactsRouter } from "../contacts/contacts.router"; import type { ConversationsRouter } from "../conversations/conversations.router"; @@ -56,6 +58,43 @@ const appRouter = t.router({ .input(completeInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) }), + agents: t.router({ + list: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + byId: publicProcedure + .input(agentIdInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + history: publicProcedure + .input(agentHistoryInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + activity: publicProcedure + .input(agentHistoryInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + update: publicProcedure + .input(agentUpdateInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + deploy: publicProcedure + .input(agentDeployInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + pause: publicProcedure + .input(agentIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + resume: publicProcedure + .input(agentIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + archive: publicProcedure + .input(agentIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + restore: publicProcedure + .input(agentIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + remove: publicProcedure + .input(agentIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + runNow: publicProcedure + .input(agentRunNowInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + }), companies: t.router({ list: publicProcedure .input(companyListInput) @@ -112,12 +151,47 @@ const appRouter = t.router({ list: publicProcedure .input(conversationListInput) .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + builderList: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + builderResources: publicProcedure + .input(builderResourceSearchInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + builderById: publicProcedure + .input(conversationIdInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), events: publicProcedure .input(conversationEventsInput) .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), save: publicProcedure .input(conversationSaveInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + createBuilder: publicProcedure + .input(builderConversationCreateInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + submitBuilder: publicProcedure + .input(builderConversationSubmitInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + answerBuilderQuestion: publicProcedure + .input(builderQuestionResponseInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + rateBuilderResponse: publicProcedure + .input(builderResponseRatingInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + markRead: publicProcedure + .input(conversationIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + shareStatus: publicProcedure + .input(conversationIdInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + createShare: publicProcedure + .input(conversationIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + revokeShare: publicProcedure + .input(conversationIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + shared: publicProcedure + .input(sharedConversationInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), remove: publicProcedure .input(conversationIdInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) diff --git a/apps/api/src/workspace/workspace.contracts.ts b/apps/api/src/workspace/workspace.contracts.ts index a79a53da..55d57784 100644 --- a/apps/api/src/workspace/workspace.contracts.ts +++ b/apps/api/src/workspace/workspace.contracts.ts @@ -1,4 +1,5 @@ import { WORKSPACE_ROLES } from "@crm/auth"; +import { MAX_SLUG } from "@crm/db/workspace"; import { z } from "zod"; import { listInput } from "../trpc/list-input"; @@ -11,6 +12,13 @@ export type MemberListInput = z.infer; export const updateWorkspaceInput = z.object({ name: z.string().trim().min(1).max(120), website: z.string().trim().min(1).max(255), + slug: z + .string() + .trim() + .min(1) + .max(MAX_SLUG) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + .optional(), }); export const setMemberRoleInput = z.object({ diff --git a/apps/api/src/workspace/workspace.service.ts b/apps/api/src/workspace/workspace.service.ts index bce1d687..505a0a10 100644 --- a/apps/api/src/workspace/workspace.service.ts +++ b/apps/api/src/workspace/workspace.service.ts @@ -144,7 +144,7 @@ export class WorkspaceService { where: { id: WORKSPACE_ID }, data: { name: input.name, - slug: workspaceSlug(input.name), + slug: workspaceSlug(input.slug ?? input.name), website, metadata: markOnboarded(before?.metadata ?? null, new Date()), }, diff --git a/apps/api/test/agent-delete.spec.ts b/apps/api/test/agent-delete.spec.ts new file mode 100644 index 00000000..ed897915 --- /dev/null +++ b/apps/api/test/agent-delete.spec.ts @@ -0,0 +1,264 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; +import { db } from "@crm/db"; +import { workspaceSlug } from "@crm/db/workspace"; +import { AgentAccessService } from "../src/agent/agent-access.service"; +import { AgentDefinitionsService } from "../src/agent/agent-definitions.service"; + +const suffix = crypto.randomUUID(); +const userId = `agent-delete-user-${suffix}`; +const memberId = `agent-delete-member-${suffix}`; +const idempotencyPrefix = `agent-delete-${suffix}`; + +const access = new AgentAccessService(db); +const agents = new AgentDefinitionsService(db, access); + +let agentId: string; +let versionId: string; +let triggerId: string; +let queuedRunId: string; +let waitingRunId: string; +let runningRunId: string; +let deliveryRunId: string; + +async function clean() { + if (agentId) { + await db.agentRunEvent.deleteMany({ where: { run: { agentId } } }); + await db.agentAction.deleteMany({ where: { agentId } }); + await db.agentAuditEvent.deleteMany({ where: { agentId } }); + await db.agentRun.deleteMany({ where: { agentId } }); + await db.agentTrigger.deleteMany({ where: { agentId } }); + await db.agentDefinition.updateMany({ + where: { id: agentId }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ where: { agentId } }); + await db.agentDefinition.deleteMany({ where: { id: agentId } }); + } + + await db.member.deleteMany({ where: { id: memberId } }); + await db.user.deleteMany({ where: { id: userId } }); +} + +beforeAll(async () => { + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + update: {}, + create: { + id: WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), + createdAt: new Date(), + }, + }); + await db.user.create({ + data: { + id: userId, + name: "Agent Delete Test", + email: `${userId}@example.test`, + }, + }); + await db.member.create({ + data: { + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }, + }); + + const agent = await db.agentDefinition.create({ + data: { + name: "Delete me", + status: "PAUSED", + createdById: userId, + }, + select: { id: true }, + }); + agentId = agent.id; + + const version = await db.agentVersion.create({ + data: { + agentId, + number: 1, + status: "DEPLOYED", + instructions: "Test deletion behavior.", + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + approvedAt: new Date(), + deployedAt: new Date(), + }, + select: { id: true }, + }); + versionId = version.id; + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: versionId }, + }); + + const trigger = await db.agentTrigger.create({ + data: { + agentId, + versionId, + type: "SCHEDULE", + name: "Every hour", + config: { intervalMinutes: 60 }, + createdById: userId, + enabled: true, + nextRunAt: new Date(Date.now() + 60 * 60 * 1000), + }, + select: { id: true }, + }); + triggerId = trigger.id; + + const [queued, waiting, running, delivery] = await Promise.all([ + db.agentRun.create({ + data: { + agentId, + versionId, + triggerId, + triggerType: "SCHEDULE", + status: "QUEUED", + idempotencyKey: `${idempotencyPrefix}-queued`, + correlationId: `${idempotencyPrefix}-queued`, + }, + select: { id: true }, + }), + db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "MANUAL", + status: "WAITING_FOR_APPROVAL", + idempotencyKey: `${idempotencyPrefix}-waiting`, + correlationId: `${idempotencyPrefix}-waiting`, + }, + select: { id: true }, + }), + db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "MANUAL", + status: "RUNNING", + idempotencyKey: `${idempotencyPrefix}-running`, + correlationId: `${idempotencyPrefix}-running`, + startedAt: new Date(), + sessionId: `${idempotencyPrefix}-active-session`, + }, + select: { id: true }, + }), + db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "MANUAL", + status: "RUNNING", + idempotencyKey: `${idempotencyPrefix}-delivery`, + correlationId: `${idempotencyPrefix}-delivery`, + startedAt: new Date(), + }, + select: { id: true }, + }), + ]); + queuedRunId = queued.id; + waitingRunId = waiting.id; + runningRunId = running.id; + deliveryRunId = delivery.id; +}); + +afterAll(async () => { + await clean(); +}); + +describe("deleting an agent", () => { + it("stops future work while preserving its audit history", async () => { + const removed = await agents.remove(agentId, userId); + + expect(removed.status).toBe("DELETED"); + expect(removed.disabledTriggers).toBe(1); + expect(removed.cancelledRuns).toBe(3); + + const [definition, trigger, runs, runEvents, auditEvent, listed] = + await Promise.all([ + db.agentDefinition.findUnique({ where: { id: agentId } }), + db.agentTrigger.findUnique({ where: { id: triggerId } }), + db.agentRun.findMany({ + where: { + id: { + in: [queuedRunId, waitingRunId, runningRunId, deliveryRunId], + }, + }, + select: { + id: true, + status: true, + errorCode: true, + finishedAt: true, + }, + }), + db.agentRunEvent.findMany({ + where: { + runId: { in: [queuedRunId, waitingRunId, deliveryRunId] }, + }, + select: { runId: true, type: true, data: true }, + }), + db.agentAuditEvent.findFirst({ + where: { agentId, type: "agent.deleted" }, + }), + agents.list(userId), + ]); + + expect(definition?.deletedAt).not.toBeNull(); + expect(trigger).toMatchObject({ enabled: false, nextRunAt: null }); + expect( + runs.filter((run) => + [queuedRunId, waitingRunId, deliveryRunId].includes(run.id), + ), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "CANCELLED", + errorCode: "AGENT_DELETED", + finishedAt: expect.any(Date), + }), + expect.objectContaining({ + status: "CANCELLED", + errorCode: "AGENT_DELETED", + finishedAt: expect.any(Date), + }), + expect.objectContaining({ + status: "CANCELLED", + errorCode: "AGENT_DELETED", + finishedAt: expect.any(Date), + }), + ]), + ); + expect(runs.find((run) => run.id === runningRunId)?.status).toBe("RUNNING"); + expect(runEvents).toHaveLength(3); + expect(runEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "run.cancelled", + data: { reason: "agent.deleted" }, + }), + ]), + ); + expect(auditEvent?.after).toEqual({ + status: "DELETED", + disabledTriggers: 1, + cancelledRuns: 3, + }); + expect(listed.some((agent) => agent.id === agentId)).toBe(false); + + let lookupError: unknown; + try { + await agents.byId(agentId, userId); + } catch (error) { + lookupError = error; + } + expect((lookupError as Error).message).toBe(`No agent with id ${agentId}.`); + }); +}); diff --git a/apps/api/test/agent-lifecycle.spec.ts b/apps/api/test/agent-lifecycle.spec.ts new file mode 100644 index 00000000..97e6627e --- /dev/null +++ b/apps/api/test/agent-lifecycle.spec.ts @@ -0,0 +1,426 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; +import { db } from "@crm/db"; +import { workspaceSlug } from "@crm/db/workspace"; +import { AgentAccessService } from "../src/agent/agent-access.service"; +import { AgentDefinitionsService } from "../src/agent/agent-definitions.service"; + +const suffix = crypto.randomUUID(); +const userId = `agent-lifecycle-user-${suffix}`; +const teammateId = `agent-lifecycle-teammate-${suffix}`; +const memberId = `agent-lifecycle-member-${suffix}`; +const teammateMemberId = `agent-lifecycle-teammate-member-${suffix}`; +const access = new AgentAccessService(db); +const agents = new AgentDefinitionsService(db, access); + +beforeAll(async () => { + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + update: {}, + create: { + id: WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), + createdAt: new Date(), + }, + }); + await db.user.createMany({ + data: [ + { + id: userId, + name: "Agent Lifecycle Test", + email: `${userId}@example.test`, + }, + { + id: teammateId, + name: "Agent Lifecycle Teammate", + email: `${teammateId}@example.test`, + }, + ], + }); + await db.member.createMany({ + data: [ + { + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }, + { + id: teammateMemberId, + organizationId: WORKSPACE_ID, + userId: teammateId, + role: "member", + createdAt: new Date(), + }, + ], + }); +}); + +afterAll(async () => { + const agentIds = ( + await db.agentDefinition.findMany({ + where: { createdById: userId }, + select: { id: true }, + }) + ).map((agent) => agent.id); + if (agentIds.length > 0) { + await db.agentRunEvent.deleteMany({ + where: { run: { agentId: { in: agentIds } } }, + }); + await db.agentAction.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentAuditEvent.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentRun.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentTrigger.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentBuilderArtifact.deleteMany({ + where: { version: { agentId: { in: agentIds } } }, + }); + await db.agentDefinition.updateMany({ + where: { id: { in: agentIds } }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentDefinition.deleteMany({ + where: { id: { in: agentIds } }, + }); + } + await db.member.deleteMany({ + where: { id: { in: [memberId, teammateMemberId] } }, + }); + await db.user.deleteMany({ where: { id: { in: [userId, teammateId] } } }); +}); + +async function createAgent(versionCount = 1, status = "DRAFT" as const) { + const agent = await db.agentDefinition.create({ + data: { + name: `Lifecycle agent ${crypto.randomUUID()}`, + status, + createdById: userId, + }, + select: { id: true }, + }); + const versions = []; + for (let number = 1; number <= versionCount; number += 1) { + versions.push( + await db.agentVersion.create({ + data: { + agentId: agent.id, + number, + status: "READY", + instructions: `Version ${number}`, + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }, + select: { id: true, number: true }, + }), + ); + } + return { agentId: agent.id, versions }; +} + +describe("agent lifecycle", () => { + it("returns stable private and team contracts while auditing metadata edits", async () => { + const { agentId, versions } = await createAgent(); + const versionId = versions[0]?.id; + if (!versionId) throw new Error("Missing version"); + + const updated = await agents.update( + { + id: agentId, + name: "Renewal monitor", + description: " ", + }, + userId, + ); + expect(updated).toMatchObject({ + id: agentId, + name: "Renewal monitor", + description: null, + status: "DRAFT", + }); + expect((await agents.byId(agentId, userId)).canManage).toBe(true); + expect( + (await agents.list(userId)).some((agent) => agent.id === agentId), + ).toBe(false); + let privateDraftError: unknown; + try { + await agents.byId(agentId, teammateId); + } catch (error) { + privateDraftError = error; + } + expect((privateDraftError as Error).message).toBe( + `No agent with id ${agentId}.`, + ); + + await agents.deploy( + { id: agentId, versionId, clientRequestId: crypto.randomUUID() }, + userId, + ); + const nextRunAt = new Date("2026-08-06T12:00:00.000Z"); + const lastRunAt = new Date("2026-08-05T12:00:00.000Z"); + await db.agentTrigger.create({ + data: { + agentId, + versionId, + type: "SCHEDULE", + name: "Every morning", + config: { intervalMinutes: 1440 }, + createdById: userId, + enabled: true, + nextRunAt, + lastRunAt, + }, + }); + + const detail = await agents.byId(agentId, teammateId); + const listed = await agents.list(teammateId); + const audit = await db.agentAuditEvent.findFirstOrThrow({ + where: { agentId, type: "agent.updated" }, + }); + const listItem = listed.find((agent) => agent.id === agentId); + expect(detail).toMatchObject({ + id: agentId, + canManage: false, + status: "LIVE", + currentVersion: { + id: versionId, + approvedAt: expect.any(String), + deployedAt: expect.any(String), + }, + triggers: [ + { + name: "Every morning", + nextRunAt: nextRunAt.toISOString(), + lastRunAt: lastRunAt.toISOString(), + }, + ], + }); + expect(detail.createdAt).toBe(new Date(detail.createdAt).toISOString()); + expect(detail.updatedAt).toBe(new Date(detail.updatedAt).toISOString()); + expect(listItem).toMatchObject({ + id: agentId, + status: "LIVE", + currentVersion: { id: versionId, deployedAt: expect.any(String) }, + triggers: [{ nextRunAt: nextRunAt.toISOString() }], + runCount: 0, + }); + expect(audit.before).toEqual({ + name: expect.stringContaining("Lifecycle agent"), + description: null, + }); + expect(audit.after).toEqual({ + name: "Renewal monitor", + description: null, + }); + }); + + it("promotes versioned draft metadata only when that version is deployed", async () => { + const { agentId, versions } = await createAgent(); + const versionId = versions[0]?.id; + if (!versionId) throw new Error("Missing version"); + + await db.agentVersion.update({ + where: { id: versionId }, + data: { + manifest: { + name: "Pipeline health monitor", + description: "Summarize pipeline health for the team.", + }, + }, + }); + const before = await db.agentDefinition.findUniqueOrThrow({ + where: { id: agentId }, + select: { name: true, description: true, status: true }, + }); + expect(before).toMatchObject({ + name: expect.stringContaining("Lifecycle agent"), + description: null, + status: "DRAFT", + }); + + await agents.deploy( + { id: agentId, versionId, clientRequestId: crypto.randomUUID() }, + userId, + ); + + const after = await db.agentDefinition.findUniqueOrThrow({ + where: { id: agentId }, + select: { + name: true, + description: true, + status: true, + currentVersionId: true, + }, + }); + expect(after).toEqual({ + name: "Pipeline health monitor", + description: "Summarize pipeline health for the team.", + status: "LIVE", + currentVersionId: versionId, + }); + }); + + it("keeps a draft private when pause or archive is requested", async () => { + const { agentId } = await createAgent(); + + const errors: Error[] = []; + for (const transition of [agents.pause, agents.archive]) { + try { + await transition.call(agents, agentId, userId); + } catch (error) { + errors.push(error as Error); + } + } + expect(errors.map((error) => error.message)).toEqual([ + "Only a live agent can be paused.", + "Only a live or paused agent can be archived.", + ]); + + const [definition, teamAgents] = await Promise.all([ + db.agentDefinition.findUnique({ where: { id: agentId } }), + agents.list(userId), + ]); + expect(definition?.status).toBe("DRAFT"); + expect(teamAgents.some((agent) => agent.id === agentId)).toBe(false); + }); + + it("moves only through valid deployed lifecycle states", async () => { + const { agentId, versions } = await createAgent(2); + const firstVersionId = versions[0]?.id; + const secondVersionId = versions[1]?.id; + if (!firstVersionId || !secondVersionId) + throw new Error("Missing versions"); + + await agents.deploy( + { + id: agentId, + versionId: firstVersionId, + clientRequestId: crypto.randomUUID(), + }, + userId, + ); + await agents.pause(agentId, userId); + await agents.resume(agentId, userId); + await agents.archive(agentId, userId); + await agents.restore(agentId, userId); + await agents.deploy( + { + id: agentId, + versionId: secondVersionId, + clientRequestId: crypto.randomUUID(), + }, + userId, + ); + + const [definition, deployedVersions] = await Promise.all([ + db.agentDefinition.findUnique({ where: { id: agentId } }), + db.agentVersion.findMany({ + where: { agentId, status: "DEPLOYED" }, + select: { id: true }, + }), + ]); + expect(definition).toMatchObject({ + status: "LIVE", + currentVersionId: secondVersionId, + archivedAt: null, + }); + expect(deployedVersions).toEqual([{ id: secondVersionId }]); + }); + + it("serializes deployments and preserves request idempotency", async () => { + const { agentId, versions } = await createAgent(2); + const firstVersionId = versions[0]?.id; + const secondVersionId = versions[1]?.id; + if (!firstVersionId || !secondVersionId) + throw new Error("Missing versions"); + const clientRequestId = crypto.randomUUID(); + + const collision = await Promise.allSettled([ + agents.deploy( + { id: agentId, versionId: firstVersionId, clientRequestId }, + userId, + ), + agents.deploy( + { id: agentId, versionId: secondVersionId, clientRequestId }, + userId, + ), + ]); + expect( + collision.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + collision.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + + const definition = await db.agentDefinition.findUniqueOrThrow({ + where: { id: agentId }, + select: { currentVersionId: true }, + }); + const currentVersionId = definition.currentVersionId; + if (!currentVersionId) throw new Error("Missing current version"); + const retries = await Promise.all( + Array.from({ length: 4 }, () => + agents.deploy( + { + id: agentId, + versionId: currentVersionId, + clientRequestId, + }, + userId, + ), + ), + ); + expect(new Set(retries.map((retry) => retry.versionId))).toEqual( + new Set([currentVersionId]), + ); + expect( + await db.agentAuditEvent.count({ + where: { agentId, type: "agent.deployed", requestId: clientRequestId }, + }), + ).toBe(1); + expect( + await db.agentVersion.count({ + where: { agentId, status: "DEPLOYED" }, + }), + ).toBe(1); + }); + + it("cannot resurrect an agent when deletion races a transition", async () => { + const { agentId, versions } = await createAgent(); + const versionId = versions[0]?.id; + if (!versionId) throw new Error("Missing version"); + await agents.deploy( + { + id: agentId, + versionId, + clientRequestId: crypto.randomUUID(), + }, + userId, + ); + + await Promise.allSettled([ + agents.pause(agentId, userId), + agents.remove(agentId, userId), + ]); + + expect( + await db.agentDefinition.findUnique({ + where: { id: agentId }, + select: { status: true }, + }), + ).toEqual({ status: "DELETED" }); + }); +}); diff --git a/apps/api/test/agent-runs.spec.ts b/apps/api/test/agent-runs.spec.ts new file mode 100644 index 00000000..92ee4c96 --- /dev/null +++ b/apps/api/test/agent-runs.spec.ts @@ -0,0 +1,303 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; +import { db } from "@crm/db"; +import { workspaceSlug } from "@crm/db/workspace"; +import { AgentAccessService } from "../src/agent/agent-access.service"; +import { AgentRunsService } from "../src/agent/agent-runs.service"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; + +const suffix = crypto.randomUUID(); +const userId = `agent-run-user-${suffix}`; +const outsiderId = `agent-run-outsider-${suffix}`; +const memberId = `agent-run-member-${suffix}`; +let agentId = ""; +let versionId = ""; +let pokeCount = 0; +const trigger = { + deployedAgentRunQueued() { + pokeCount += 1; + }, +} as AgentTriggerService; +const service = new AgentRunsService(db, new AgentAccessService(db), trigger); + +beforeAll(async () => { + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + update: {}, + create: { + id: WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), + createdAt: new Date(), + }, + }); + await db.user.createMany({ + data: [ + { + id: userId, + name: "Agent Run Test", + email: `${userId}@example.test`, + }, + { + id: outsiderId, + name: "Agent Run Outsider", + email: `${outsiderId}@example.test`, + }, + ], + }); + await db.member.create({ + data: { + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }, + }); + const agent = await db.agentDefinition.create({ + data: { name: "Run safely", status: "LIVE", createdById: userId }, + select: { id: true }, + }); + agentId = agent.id; + const version = await db.agentVersion.create({ + data: { + agentId, + number: 1, + status: "DEPLOYED", + instructions: "Run once.", + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }, + select: { id: true }, + }); + versionId = version.id; + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: versionId }, + }); +}); + +afterAll(async () => { + const agentIds = ( + await db.agentDefinition.findMany({ + where: { createdById: userId }, + select: { id: true }, + }) + ).map((agent) => agent.id); + if (agentIds.length > 0) { + await db.agentRunEvent.deleteMany({ + where: { run: { agentId: { in: agentIds } } }, + }); + await db.agentAction.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentAuditEvent.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentRun.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentDefinition.updateMany({ + where: { id: { in: agentIds } }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ + where: { agentId: { in: agentIds } }, + }); + await db.agentDefinition.deleteMany({ + where: { id: { in: agentIds } }, + }); + } + await db.member.deleteMany({ where: { id: memberId } }); + await db.user.deleteMany({ where: { id: { in: [userId, outsiderId] } } }); +}); + +describe("manual agent runs", () => { + it("returns ordered, transport-safe run and activity history", async () => { + const clientRequestId = crypto.randomUUID(); + const { id: runId } = await service.runNow( + { id: agentId, clientRequestId }, + userId, + ); + const startedAt = new Date("2026-08-05T12:00:00.000Z"); + const completedAt = new Date("2026-08-05T12:00:02.000Z"); + await db.agentRun.update({ + where: { id: runId }, + data: { + status: "SUCCEEDED", + summary: "Prepared the account brief", + modelId: "test/model", + inputTokens: 120, + outputTokens: 80, + costUsd: "0.012345", + startedAt, + finishedAt: completedAt, + }, + }); + await db.agentRunEvent.create({ + data: { + runId, + sequence: 1, + type: "run.completed", + data: { summary: "Prepared the account brief" }, + emittedAt: completedAt, + }, + }); + await db.agentAction.create({ + data: { + agentId, + runId, + type: "timeline.note.created", + provider: "crm", + targetType: "company", + targetId: "company-1", + targetLabel: "Acme", + summary: "Added a meeting brief", + status: "SUCCEEDED", + idempotencyKey: `action-${clientRequestId}`, + plannedAt: startedAt, + startedAt, + completedAt, + }, + }); + + const [runs, activity] = await Promise.all([ + service.list(agentId, 1, userId), + service.activity(agentId, 1, userId), + ]); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + id: runId, + status: "SUCCEEDED", + costUsd: "0.012345", + startedAt: startedAt.toISOString(), + finishedAt: completedAt.toISOString(), + events: [ + { sequence: 0, type: "run.queued", emittedAt: expect.any(String) }, + { + sequence: 1, + type: "run.completed", + emittedAt: completedAt.toISOString(), + }, + ], + actions: [ + { + type: "timeline.note.created", + plannedAt: startedAt.toISOString(), + startedAt: startedAt.toISOString(), + completedAt: completedAt.toISOString(), + }, + ], + }); + expect(runs[0]?.createdAt).toBe( + new Date(runs[0]?.createdAt ?? "").toISOString(), + ); + expect(activity).toHaveLength(1); + expect(activity[0]).toMatchObject({ + type: "run.requested", + requestId: clientRequestId, + emittedAt: expect.any(String), + actorUser: { id: userId }, + version: { id: versionId, number: 1 }, + }); + }); + + it("rejects a manual run while an agent is not live", async () => { + const beforePokeCount = pokeCount; + const draft = await db.agentDefinition.create({ + data: { + name: "Draft run guard", + status: "DRAFT", + createdById: userId, + }, + select: { id: true }, + }); + + let runError: unknown; + try { + await service.runNow( + { id: draft.id, clientRequestId: crypto.randomUUID() }, + userId, + ); + } catch (error) { + runError = error; + } + expect((runError as Error).message).toBe("This agent is not live yet."); + expect(pokeCount).toBe(beforePokeCount); + }); + + it("deduplicates concurrent requests and keeps the run and audit atomic", async () => { + const beforePokeCount = pokeCount; + const clientRequestId = crypto.randomUUID(); + const results = await Promise.all( + Array.from({ length: 4 }, () => + service.runNow({ id: agentId, clientRequestId }, userId), + ), + ); + + expect(new Set(results.map((result) => result.id)).size).toBe(1); + expect( + await db.agentRun.count({ where: { idempotencyKey: clientRequestId } }), + ).toBe(1); + expect( + await db.agentAuditEvent.count({ + where: { agentId, type: "run.requested", requestId: clientRequestId }, + }), + ).toBe(1); + expect(pokeCount).toBe(beforePokeCount + 4); + }); + + it("checks workspace membership before replaying an existing request", async () => { + const clientRequestId = crypto.randomUUID(); + await service.runNow({ id: agentId, clientRequestId }, userId); + + let error: Error | null = null; + try { + await service.runNow({ id: agentId, clientRequestId }, outsiderId); + } catch (caught) { + error = caught as Error; + } + expect(error?.message).toBe("You are not a member of this workspace."); + }); + + it("allows only one agent to claim a globally reused request id", async () => { + const otherAgent = await db.agentDefinition.create({ + data: { name: "Other live agent", status: "LIVE", createdById: userId }, + select: { id: true }, + }); + const otherVersion = await db.agentVersion.create({ + data: { + agentId: otherAgent.id, + number: 1, + status: "DEPLOYED", + instructions: "Run once.", + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }, + select: { id: true }, + }); + await db.agentDefinition.update({ + where: { id: otherAgent.id }, + data: { currentVersionId: otherVersion.id }, + }); + const clientRequestId = crypto.randomUUID(); + + const attempts = await Promise.allSettled([ + service.runNow({ id: agentId, clientRequestId }, userId), + service.runNow({ id: otherAgent.id, clientRequestId }, userId), + ]); + expect( + attempts.filter((attempt) => attempt.status === "fulfilled"), + ).toHaveLength(1); + expect( + attempts.filter((attempt) => attempt.status === "rejected"), + ).toHaveLength(1); + expect( + await db.agentRun.count({ where: { idempotencyKey: clientRequestId } }), + ).toBe(1); + }); +}); diff --git a/apps/api/test/agent-visibility.spec.ts b/apps/api/test/agent-visibility.spec.ts new file mode 100644 index 00000000..8c012f78 --- /dev/null +++ b/apps/api/test/agent-visibility.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test"; +import { + canReadAgent, + isPrivateAgentDraft, + TEAM_AGENT_STATUSES, +} from "../src/agent/agent-visibility"; + +describe("agent visibility", () => { + it("keeps drafts private to their creator", () => { + expect(canReadAgent("DRAFT", "creator", "creator")).toBe(true); + expect(canReadAgent("DRAFT", "creator", "teammate")).toBe(false); + expect(canReadAgent("DEPLOYING", "creator", "teammate")).toBe(false); + }); + + it("makes deployed lifecycle states visible to the team", () => { + for (const status of TEAM_AGENT_STATUSES) { + expect(canReadAgent(status, "creator", "teammate")).toBe(true); + } + }); + + it("treats deployment as the ownership boundary", () => { + expect(isPrivateAgentDraft("DRAFT")).toBe(true); + expect(isPrivateAgentDraft("DEPLOYING")).toBe(true); + expect(isPrivateAgentDraft("LIVE")).toBe(false); + }); +}); diff --git a/apps/api/test/conversation-sharing.spec.ts b/apps/api/test/conversation-sharing.spec.ts new file mode 100644 index 00000000..b33b5f90 --- /dev/null +++ b/apps/api/test/conversation-sharing.spec.ts @@ -0,0 +1,225 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; +import { db } from "@crm/db"; +import { workspaceSlug } from "@crm/db/workspace"; +import { ConversationSharingService } from "../src/conversations/conversation-sharing.service"; +import { ConversationsService } from "../src/conversations/conversations.service"; + +const suffix = crypto.randomUUID(); +const userId = `share-user-${suffix}`; +const outsiderId = `share-outsider-${suffix}`; +const viewerId = `share-viewer-${suffix}`; +const memberId = `share-member-${suffix}`; +const viewerMemberId = `share-viewer-member-${suffix}`; +const sessionId = `share-session-${suffix}`; +let conversationId = ""; +let attachmentId = ""; +const service = new ConversationSharingService(db); +const conversations = new ConversationsService(db); + +beforeAll(async () => { + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + update: {}, + create: { + id: WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), + createdAt: new Date(), + }, + }); + await db.user.createMany({ + data: [ + { id: userId, name: "Share Owner", email: `${userId}@example.test` }, + { + id: outsiderId, + name: "Share Outsider", + email: `${outsiderId}@example.test`, + }, + { + id: viewerId, + name: "Share Viewer", + email: `${viewerId}@example.test`, + }, + ], + }); + await db.member.createMany({ + data: [ + { + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }, + { + id: viewerMemberId, + organizationId: WORKSPACE_ID, + userId: viewerId, + role: "member", + createdAt: new Date(), + }, + ], + }); + const conversation = await db.agentConversation.create({ + data: { + kind: "BUILDER", + userId, + title: "Share safely", + sessionId, + }, + select: { id: true }, + }); + conversationId = conversation.id; + const submission = await db.agentConversationSubmission.create({ + data: { + conversationId, + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { + text: "Review the image", + resources: [], + attachments: [{ name: "shared.png", type: "image/png", size: 4 }], + }, + attachments: { + create: { + name: "shared.png", + mediaType: "image/png", + size: 4, + content: Buffer.from([1, 2, 3, 4]), + }, + }, + }, + select: { attachments: { select: { id: true } } }, + }); + attachmentId = submission.attachments[0]?.id ?? ""; +}); + +afterAll(async () => { + await db.agentEvent.deleteMany({ where: { sessionId } }); + await db.agentConversation.deleteMany({ where: { id: conversationId } }); + await db.member.deleteMany({ + where: { id: { in: [memberId, viewerMemberId] } }, + }); + await db.user.deleteMany({ + where: { id: { in: [userId, outsiderId, viewerId] } }, + }); +}); + +describe("conversation sharing", () => { + it("keeps a builder chat private until its owner creates a link", async () => { + let unavailable: unknown; + try { + await service.resolve("x".repeat(43), userId); + } catch (error) { + unavailable = error; + } + expect(unavailable).toBeDefined(); + + const { token } = await service.create(conversationId, userId); + expect(await service.resolve(token, userId)).toMatchObject({ + id: conversationId, + title: "Share safely", + ownerName: "Share Owner", + }); + }); + + it("allows only the owner to create or revoke a chat link", async () => { + for (const operation of [ + () => service.create(conversationId, outsiderId), + () => service.revoke(conversationId, outsiderId), + ]) { + let denied: unknown; + try { + await operation(); + } catch (error) { + denied = error; + } + expect(denied).toBeDefined(); + } + }); + + it("keeps exactly one active link across concurrent replacements", async () => { + const results = await Promise.all( + Array.from({ length: 4 }, () => service.create(conversationId, userId)), + ); + const activeShares = await db.agentConversationShare.findMany({ + where: { conversationId, revokedAt: null }, + select: { tokenHash: true }, + }); + + expect(activeShares).toHaveLength(1); + const resolutions = await Promise.allSettled( + results.map(({ token }) => service.resolve(token, userId)), + ); + expect( + resolutions.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + }); + + it("requires workspace membership to open a valid shared link", async () => { + const { token } = await service.create(conversationId, userId); + let denied: unknown; + try { + await service.resolve(token, outsiderId); + } catch (error) { + denied = error; + } + + expect(denied).toBeDefined(); + }); + + it("authorizes attachment bytes with the active share token only", async () => { + const { token } = await service.create(conversationId, userId); + const shared = await service.resolve(token, viewerId); + const message = recordOf(shared.submissions[0]?.message); + const attachment = recordOf(arrayOf(message.attachments)[0]); + expect(attachment.previewUrl).toBe( + `/api/conversations/attachments/${attachmentId}?share=${encodeURIComponent(token)}`, + ); + expect( + Buffer.from( + (await conversations.attachment(attachmentId, viewerId, token)).content, + ), + ).toEqual(Buffer.from([1, 2, 3, 4])); + + await service.revoke(conversationId, userId); + let revokedError: unknown; + try { + await conversations.attachment(attachmentId, viewerId, token); + } catch (error) { + revokedError = error; + } + expect((revokedError as Error).message).toBe( + "That attachment is unavailable.", + ); + }); + + it("revokes the active link", async () => { + const { token } = await service.create(conversationId, userId); + await service.revoke(conversationId, userId); + + expect( + await db.agentConversationShare.count({ + where: { conversationId, revokedAt: null }, + }), + ).toBe(0); + let unavailable: unknown; + try { + await service.resolve(token, userId); + } catch (error) { + unavailable = error; + } + expect(unavailable).toBeDefined(); + }); +}); + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function arrayOf(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} diff --git a/apps/api/test/conversations.spec.ts b/apps/api/test/conversations.spec.ts index 8bc5bbb5..f0df2e0c 100644 --- a/apps/api/test/conversations.spec.ts +++ b/apps/api/test/conversations.spec.ts @@ -1,51 +1,79 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; import { db } from "@crm/db"; +import { workspaceSlug } from "@crm/db/workspace"; +import { + builderConversationCreateInput, + conversationListInput, + conversationSaveInput, +} from "../src/conversations/conversations.contracts"; import { ConversationsService } from "../src/conversations/conversations.service"; const suffix = process.env.TEST_RUN_ID ?? "conversations-spec"; const email = `conversation.subject.${suffix}@example.test`; const userId = `user-${suffix}`; - -function memoryCache() { - const store = new Map(); - return { - store, - get: async (key: string) => store.get(key) as T | undefined, - set: async (key: string, value: unknown) => { - store.set(key, value); - }, - del: async (key: string) => { - store.delete(key); - }, - }; -} +const memberId = `conversation-member-${suffix}`; let contactId: string; let service: ConversationsService; -let cache: ReturnType; beforeAll(async () => { + await db.agentEvent.deleteMany({ + where: { + OR: [ + { sessionId: { startsWith: `builder-question-${suffix}` } }, + { sessionId: { startsWith: `ses_${suffix}` } }, + ], + }, + }); + await db.agentConversation.deleteMany({ where: { userId } }); + await db.member.deleteMany({ where: { id: memberId } }); await db.user.deleteMany({ where: { id: userId } }); await db.contact.deleteMany({ where: { email } }); + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + update: {}, + create: { + id: WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), + createdAt: new Date(), + }, + }); await db.user.create({ data: { id: userId, name: "Test Rep", email: `${userId}@example.test` }, }); + await db.member.create({ + data: { + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }, + }); const contact = await db.contact.create({ data: { firstName: "Conversation", lastName: "Subject", email }, select: { id: true }, }); contactId = contact.id; - cache = memoryCache(); - service = new ConversationsService( - db, - cache as unknown as ConstructorParameters[1], - ); + service = new ConversationsService(db); }); afterAll(async () => { + await db.agentEvent.deleteMany({ + where: { + OR: [ + { sessionId: { startsWith: `builder-question-${suffix}` } }, + { sessionId: { startsWith: `ses_${suffix}` } }, + ], + }, + }); await db.contact.deleteMany({ where: { email } }); + await db.agentConversation.deleteMany({ where: { userId } }); + await db.member.deleteMany({ where: { id: memberId } }); await db.user.deleteMany({ where: { id: userId } }); }); @@ -100,17 +128,15 @@ describe("ConversationsService", () => { }); }); - it("serves the list from cache, and drops it when something changes", async () => { - await service.list({ contactId }, userId); - expect(cache.store.size).toBe(1); - + it("reflects newly saved conversations immediately", async () => { + const before = await service.list({ contactId }, userId); await service.save( { contactId, sessionId: `ses_${suffix}_2`, messageCount: 1 }, userId, ); - expect(cache.store.size).toBe(0); - - expect(await service.list({ contactId }, userId)).toHaveLength(2); + expect(await service.list({ contactId }, userId)).toHaveLength( + before.length + 1, + ); }); it("newest first, so reopening lands on the last thing you asked", async () => { @@ -123,19 +149,150 @@ describe("ConversationsService", () => { }); it("refuses a conversation that belongs to a record of neither kind", async () => { - expect( + await expect( service.save({ sessionId: `ses_${suffix}_3` }, userId), ).rejects.toThrow(); }); + it("requires exactly one CRM record in list and save inputs", () => { + expect(conversationListInput.safeParse({}).success).toBe(false); + expect( + conversationListInput.safeParse({ contactId, companyId: "company-1" }) + .success, + ).toBe(false); + expect( + conversationSaveInput.safeParse({ + contactId, + dealId: "deal-1", + sessionId: "session-1", + }).success, + ).toBe(false); + }); + + it("does not mutate a conversation owned by another rep", async () => { + const sessionId = `ses_${suffix}_ownership`; + await service.save( + { + contactId, + sessionId, + continuationToken: "owner-token", + streamIndex: 3, + }, + userId, + ); + + let ownershipError: unknown; + try { + await service.save( + { + contactId, + sessionId, + continuationToken: "attacker-token", + streamIndex: 99, + }, + "somebody-else", + ); + } catch (error) { + ownershipError = error; + } + expect(ownershipError).toBeDefined(); + + expect( + await db.agentConversation.findUnique({ + where: { sessionId }, + select: { continuationToken: true, streamIndex: true }, + }), + ).toEqual({ continuationToken: "owner-token", streamIndex: 3 }); + }); + + it("does not move an existing session to another CRM record", async () => { + const sessionId = `ses_${suffix}_record`; + await service.save({ contactId, sessionId }, userId); + + let recordError: unknown; + try { + await service.save({ companyId: "another-record", sessionId }, userId); + } catch (error) { + recordError = error; + } + expect(recordError).toBeInstanceOf(Error); + expect((recordError as Error).message).toContain("cannot be moved"); + }); + + it("deduplicates concurrent saves of the same record session", async () => { + const sessionId = `ses_${suffix}_concurrent`; + const results = await Promise.all( + Array.from({ length: 4 }, () => + service.save({ contactId, sessionId, streamIndex: 7 }, userId), + ), + ); + + expect(new Set(results.map((result) => result.id)).size).toBe(1); + expect(await db.agentConversation.count({ where: { sessionId } })).toBe(1); + }); + + it("does not treat a builder session as a record conversation", async () => { + const builder = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CHAT", + message: "Summarize this customer", + resources: [], + attachments: [], + }, + userId, + ); + const sessionId = `ses_${suffix}_builder`; + await db.agentConversation.update({ + where: { id: builder.id }, + data: { sessionId }, + }); + + let saveError: unknown; + try { + await service.save({ contactId, sessionId }, userId); + } catch (error) { + saveError = error; + } + expect(saveError).toBeDefined(); + expect( + await db.agentConversation.findUnique({ + where: { id: builder.id }, + select: { kind: true, contactId: true }, + }), + ).toEqual({ kind: "BUILDER", contactId: null }); + }); + + it("returns the newest event window in chronological order", async () => { + const sessionId = `ses_${suffix}_events`; + const saved = await service.save({ contactId, sessionId }, userId); + const emittedAt = new Date("2026-08-05T12:00:00.000Z"); + await db.agentEvent.createMany({ + data: [0, 1, 2, 3].map((position) => ({ + id: `evt_${suffix}_window_${position}`, + sessionId, + contactId, + type: `event.${position}`, + data: {}, + emittedAt: new Date(emittedAt.getTime() + position), + })), + }); + + expect( + (await service.events({ id: saved.id, limit: 2 }, userId)).map( + (event) => event.type, + ), + ).toEqual(["event.2", "event.3"]); + }); + it("forgets a conversation and the events behind it", async () => { - const [conversation] = await service.list({ contactId }, userId); - if (!conversation) throw new Error("expected a conversation"); + const sessionId = `ses_${suffix}_delete`; + const conversation = await service.save({ contactId, sessionId }, userId); await db.agentEvent.create({ data: { id: `evt_${suffix}`, - sessionId: conversation.sessionId, + sessionId, contactId, type: "turn.completed", data: {}, @@ -145,18 +302,473 @@ describe("ConversationsService", () => { await service.remove(conversation.id, userId); - expect(await service.list({ contactId }, userId)).toHaveLength(1); + expect( + await db.agentConversation.findUnique({ where: { id: conversation.id } }), + ).toBeNull(); expect( await db.agentEvent.count({ - where: { sessionId: conversation.sessionId }, + where: { sessionId }, }), ).toBe(0); }); it("will not let one rep delete another's conversation", async () => { - const [conversation] = await service.list({ contactId }, userId); - if (!conversation) throw new Error("expected a conversation"); + const conversation = await service.save( + { contactId, sessionId: `ses_${suffix}_protected` }, + userId, + ); + + let removeError: unknown; + try { + await service.remove(conversation.id, "somebody-else"); + } catch (error) { + removeError = error; + } + expect(removeError).toBeDefined(); + expect( + await db.agentConversation.findUnique({ where: { id: conversation.id } }), + ).not.toBeNull(); + }); - expect(service.remove(conversation.id, "somebody-else")).rejects.toThrow(); + it("persists the command type that controls agent creation", async () => { + const chat = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CHAT", + message: "Tell me about this customer", + resources: [], + attachments: [], + }, + userId, + ); + const creation = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CREATE_AGENT", + message: "/Create agent Flag stalled deals", + resources: [], + attachments: [], + }, + userId, + ); + + expect( + (await service.builderById(chat.id, userId)).submissions[0], + ).toMatchObject({ commandType: "CHAT" }); + expect( + (await service.builderById(creation.id, userId)).submissions[0], + ).toMatchObject({ commandType: "CREATE_AGENT" }); + expect((await service.builderById(chat.id, userId)).title).toBeNull(); + expect((await service.builderById(creation.id, userId)).title).toBeNull(); + }); + + it("persists attachment bytes once and returns lightweight transcript metadata", async () => { + const imageBytes = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const textBytes = Buffer.from("account notes"); + const input = { + clientRequestId: crypto.randomUUID(), + commandType: "CHAT" as const, + message: "Review these files", + resources: [], + attachments: [ + { + name: "account.png", + type: "image/png", + size: imageBytes.byteLength, + contentBase64: imageBytes.toString("base64"), + }, + { + name: "notes.txt", + type: "text/plain", + size: textBytes.byteLength, + contentBase64: textBytes.toString("base64"), + }, + ], + }; + const conversation = await service.createBuilder(input, userId); + const detail = await service.builderById(conversation.id, userId); + const submission = detail.submissions.find( + (row) => row.clientRequestId === input.clientRequestId, + ); + const message = recordOf(submission?.message); + const attachments = arrayOf(message.attachments).map(recordOf); + + expect(JSON.stringify(message)).not.toContain("contentBase64"); + expect(attachments).toEqual([ + expect.objectContaining({ + name: "account.png", + type: "image/png", + size: imageBytes.byteLength, + previewUrl: expect.stringContaining("/api/conversations/attachments/"), + }), + expect.objectContaining({ + name: "notes.txt", + type: "text/plain", + size: textBytes.byteLength, + previewUrl: null, + }), + ]); + const imageId = attachments[0]?.id; + if (typeof imageId !== "string") throw new Error("Missing attachment id"); + const image = await service.attachment(imageId, userId); + expect(Buffer.from(image.content)).toEqual(imageBytes); + expect(image).toMatchObject({ + name: "account.png", + mediaType: "image/png", + previewable: true, + }); + }); + + it("rejects attachment metadata that does not match its bytes", () => { + expect( + builderConversationCreateInput.safeParse({ + clientRequestId: crypto.randomUUID(), + message: "Review this file", + attachments: [ + { + name: "notes.txt", + type: "text/plain", + size: 99, + contentBase64: Buffer.from("notes").toString("base64"), + }, + ], + }).success, + ).toBe(false); + }); + + it("reuses persisted attachment bytes when retrying a failed agent turn", async () => { + const bytes = Buffer.from("retry-safe attachment"); + const conversation = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CHAT", + message: "Read this attachment", + resources: [], + attachments: [ + { + name: "retry.txt", + type: "text/plain", + size: bytes.byteLength, + contentBase64: bytes.toString("base64"), + }, + ], + }, + userId, + ); + const original = await service.builderById(conversation.id, userId); + const originalMessage = recordOf(original.submissions[0]?.message); + const originalAttachment = recordOf( + arrayOf(originalMessage.attachments)[0], + ); + + await service.submitBuilder( + { + id: conversation.id, + clientRequestId: crypto.randomUUID(), + commandType: "CHAT", + message: "Try reading it again", + resources: [], + attachments: [ + { + id: String(originalAttachment.id), + name: String(originalAttachment.name), + type: String(originalAttachment.type), + size: Number(originalAttachment.size), + previewUrl: null, + }, + ], + }, + userId, + ); + + const detail = await service.builderById(conversation.id, userId); + const retryMessage = recordOf(detail.submissions.at(-1)?.message); + const retryAttachment = recordOf(arrayOf(retryMessage.attachments)[0]); + expect(retryAttachment.id).not.toBe(originalAttachment.id); + const stored = await service.attachment(String(retryAttachment.id), userId); + expect(Buffer.from(stored.content)).toEqual(bytes); + }); + + it("does not copy an attachment from another private conversation", async () => { + const bytes = Buffer.from("private attachment"); + const source = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CHAT", + message: "Private source", + resources: [], + attachments: [ + { + name: "private.txt", + type: "text/plain", + size: bytes.byteLength, + contentBase64: bytes.toString("base64"), + }, + ], + }, + userId, + ); + const target = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CHAT", + message: "Different private chat", + resources: [], + attachments: [], + }, + userId, + ); + const sourceDetail = await service.builderById(source.id, userId); + const sourceMessage = recordOf(sourceDetail.submissions[0]?.message); + const attachment = recordOf(arrayOf(sourceMessage.attachments)[0]); + + let submitError: unknown; + try { + await service.submitBuilder( + { + id: target.id, + clientRequestId: crypto.randomUUID(), + commandType: "CHAT", + message: "Copy data across chats", + resources: [], + attachments: [ + { + id: String(attachment.id), + name: String(attachment.name), + type: String(attachment.type), + size: Number(attachment.size), + previewUrl: null, + }, + ], + }, + userId, + ); + } catch (error) { + submitError = error; + } + expect(submitError).toBeInstanceOf(Error); + expect((submitError as Error).message).toContain("no longer available"); + }); + + it("deduplicates concurrent builder creation retries", async () => { + const clientRequestId = crypto.randomUUID(); + const results = await Promise.all( + Array.from({ length: 4 }, () => + service.createBuilder( + { + clientRequestId, + commandType: "CHAT", + message: "Prepare a renewal brief", + resources: [], + attachments: [], + }, + userId, + ), + ), + ); + + expect(new Set(results.map((result) => result.id)).size).toBe(1); + expect( + await db.agentConversationSubmission.count({ + where: { clientRequestId }, + }), + ).toBe(1); + }); + + it("deduplicates concurrent builder message retries", async () => { + const conversation = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CHAT", + message: "Review the account history", + resources: [], + attachments: [], + }, + userId, + ); + const clientRequestId = crypto.randomUUID(); + const results = await Promise.all( + Array.from({ length: 4 }, () => + service.submitBuilder( + { + id: conversation.id, + clientRequestId, + commandType: "CHAT", + message: "Summarize the open risks", + resources: [], + attachments: [], + }, + userId, + ), + ), + ); + + expect(new Set(results.map((result) => result.id)).size).toBe(1); + expect( + await db.agentConversationSubmission.count({ + where: { clientRequestId }, + }), + ).toBe(1); + }); + + it("queues a pending builder answer for the CRM-owned Eve channel", async () => { + const conversation = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CREATE_AGENT", + message: "/Create agent Flag overdue invoices", + resources: [], + attachments: [], + }, + userId, + ); + const sessionId = `builder-question-${suffix}-1`; + await db.agentConversation.update({ + where: { id: conversation.id }, + data: { + sessionId, + continuationToken: `crm:builder:${conversation.id}`, + }, + }); + await db.agentEvent.create({ + data: { + id: `evt_${suffix}_question`, + sessionId, + type: "input.requested", + data: { + requests: [ + { + kind: "question", + requestId: "question-1", + prompt: "Where should this go?", + display: "select", + options: [{ id: "crm-task", label: "Create a CRM task" }], + }, + ], + }, + emittedAt: new Date(), + }, + }); + + const response = await service.answerBuilderQuestion( + { + id: conversation.id, + clientRequestId: crypto.randomUUID(), + requestId: "question-1", + optionId: "crm-task", + }, + userId, + ); + const submission = await db.agentConversationSubmission.findUnique({ + where: { id: response.id }, + select: { + commandType: true, + inputRequestId: true, + message: true, + status: true, + }, + }); + + expect(submission).toMatchObject({ + commandType: "CHAT", + inputRequestId: "question-1", + status: "PENDING", + message: { + text: "Create a CRM task", + inputResponse: { + requestId: "question-1", + answer: "crm-task", + }, + }, + }); + }); + + it("accepts only one concurrent answer to a follow-up request", async () => { + const conversation = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CREATE_AGENT", + message: "/Create agent Prepare meeting briefs", + resources: [], + attachments: [], + }, + userId, + ); + const sessionId = `builder-question-${suffix}-2`; + await db.agentConversation.update({ + where: { id: conversation.id }, + data: { + sessionId, + continuationToken: `crm:builder:${conversation.id}`, + }, + }); + await db.agentEvent.create({ + data: { + id: `evt_${suffix}_concurrent_question`, + sessionId, + type: "input.requested", + data: { + requests: [ + { + kind: "question", + requestId: "question-concurrent", + prompt: "Which output?", + display: "select", + options: [ + { id: "note", label: "Create a note" }, + { id: "task", label: "Create a task" }, + ], + }, + ], + }, + emittedAt: new Date(), + }, + }); + + const results = await Promise.allSettled([ + service.answerBuilderQuestion( + { + id: conversation.id, + clientRequestId: crypto.randomUUID(), + requestId: "question-concurrent", + optionId: "note", + }, + userId, + ), + service.answerBuilderQuestion( + { + id: conversation.id, + clientRequestId: crypto.randomUUID(), + requestId: "question-concurrent", + optionId: "task", + }, + userId, + ), + ]); + + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + expect( + await db.agentConversationSubmission.count({ + where: { + conversationId: conversation.id, + inputRequestId: "question-concurrent", + }, + }), + ).toBe(1); }); }); + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function arrayOf(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts new file mode 100644 index 00000000..6e23d3fd --- /dev/null +++ b/apps/api/test/setup.ts @@ -0,0 +1,6 @@ +import { afterAll } from "bun:test"; +import { db } from "@crm/db"; + +afterAll(async () => { + await db.$disconnect(); +}); diff --git a/apps/api/turbo.json b/apps/api/turbo.json index 3714bf7c..baff6c2e 100644 --- a/apps/api/turbo.json +++ b/apps/api/turbo.json @@ -15,6 +15,7 @@ "outputs": ["src/generated/**"] }, "dev": { + "dependsOn": ["$TURBO_EXTENDS$"], "cache": false, "persistent": true, "passThroughEnv": [ diff --git a/apps/app/AGENTS.md b/apps/app/AGENTS.md index 8bd0e390..643577df 100644 --- a/apps/app/AGENTS.md +++ b/apps/app/AGENTS.md @@ -1,5 +1,9 @@ + # This is NOT the Next.js you know -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx new file mode 100644 index 00000000..741214ce --- /dev/null +++ b/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx @@ -0,0 +1,59 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Suspense } from "react"; +import { TeamAgentDetail } from "@/components/agent-builder/team-agent-detail"; +import { PageShellFallback } from "@/components/page-shell"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; + +export const metadata: Metadata = { title: "Team agent" }; + +export default function TeamAgentPage({ + params, +}: { + params: Promise<{ agentId: string }>; +}) { + return ( + }> + + + ); +} + +async function PrefetchedTeamAgent({ + params, +}: { + params: Promise<{ agentId: string }>; +}) { + const { agentId } = await params; + if (agentId === "team") notFound(); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + const agentQuery = trpc.agents.byId.queryOptions({ id: agentId }); + const runsQuery = trpc.agents.history.queryOptions({ + id: agentId, + limit: 50, + }); + const activityQuery = trpc.agents.activity.queryOptions({ + id: agentId, + limit: 100, + }); + + const [agent, runs, activity] = await Promise.all([ + queryClient.fetchQuery(agentQuery), + queryClient.fetchQuery(runsQuery), + queryClient.fetchQuery(activityQuery), + ]); + + return ( + + + + ); +} diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/agents/loading.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/agents/loading.tsx new file mode 100644 index 00000000..6884992b --- /dev/null +++ b/apps/app/app/(app)/[slug]/(agent-builder)/agents/loading.tsx @@ -0,0 +1,5 @@ +import { PageShellFallback } from "@/components/page-shell"; + +export default function AgentsLoading() { + return ; +} diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/agents/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/agents/page.tsx new file mode 100644 index 00000000..8edbb7e7 --- /dev/null +++ b/apps/app/app/(app)/[slug]/(agent-builder)/agents/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { TeamAgentsIndex } from "@/components/agent-builder/team-agents-index"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellLoading, + PageShellTitle, +} from "@/components/page-shell"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; + +export const metadata: Metadata = { title: "Agents" }; + +export default function AgentsPage() { + return ( + + + + Team agents + + Durable automations created from private agent-builder chats. + + + + + + }> + + + + + ); +} + +async function PrefetchedTeamAgents() { + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + const agents = await queryClient.fetchQuery(trpc.agents.list.queryOptions()); + + return ( + + + + ); +} diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx new file mode 100644 index 00000000..2f08c456 --- /dev/null +++ b/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { AgentBuilderChat } from "@/components/agent-builder/agent-builder-chat"; +import { isSharedChatToken } from "@/lib/chat-route"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; + +export const metadata: Metadata = { title: "Agent chat" }; + +export default function AgentChatPage({ + params, +}: { + params: Promise<{ chatId: string }>; +}) { + return ( + }> + + + ); +} + +async function PrefetchedAgentChat({ + params, +}: { + params: Promise<{ chatId: string }>; +}) { + const { chatId } = await params; + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + const sharedChat = isSharedChatToken(chatId); + + if (sharedChat) { + await queryClient.prefetchQuery( + trpc.conversations.shared.queryOptions({ token: chatId }), + ); + } else { + await queryClient.prefetchQuery( + trpc.conversations.builderById.queryOptions({ + id: chatId, + }), + ); + } + + return ; +} + +function ChatFallback() { + return ( +
+ Opening chat… +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/chat/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/chat/page.tsx new file mode 100644 index 00000000..6590103e --- /dev/null +++ b/apps/app/app/(app)/[slug]/(agent-builder)/chat/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { AgentBuilderHome } from "@/components/agent-builder/agent-builder-home"; +import { requireSession } from "@/lib/session"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; + +export const metadata: Metadata = { title: "Chat" }; + +export default function ChatPage() { + return ( + }> + + + ); +} + +async function ChatHome() { + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + + const [session] = await Promise.all([ + requireSession(), + queryClient.prefetchQuery( + trpc.conversations.builderResources.queryOptions({ q: "" }), + ), + queryClient.prefetchQuery(trpc.google.status.queryOptions()), + ]); + + return ; +} + +function ChatHomeFallback() { + return ( +
+ Opening chat… +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/layout.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/layout.tsx new file mode 100644 index 00000000..229b5844 --- /dev/null +++ b/apps/app/app/(app)/[slug]/(agent-builder)/layout.tsx @@ -0,0 +1,54 @@ +import { Suspense } from "react"; +import { AgentBuilderShell } from "@/components/agent-builder/agent-builder-shell"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; + +export default function AgentBuilderLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + + } + > + {children} + + ); +} + +function AgentBuilderContentFallback() { + return ( +
+ Loading chat… +
+ ); +} + +async function PrefetchedAgentBuilderShell({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + const conversationsQuery = trpc.conversations.builderList.queryOptions(); + const agentsQuery = trpc.agents.list.queryOptions(); + + const [conversations, agents] = await Promise.all([ + queryClient.fetchQuery(conversationsQuery), + queryClient.fetchQuery(agentsQuery), + ]); + const updatedAt = Math.min( + queryClient.getQueryState(conversationsQuery.queryKey)?.dataUpdatedAt ?? 0, + queryClient.getQueryState(agentsQuery.queryKey)?.dataUpdatedAt ?? 0, + ); + + return ( + + + {children} + + + ); +} diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/loading.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/loading.tsx new file mode 100644 index 00000000..6e32c15e --- /dev/null +++ b/apps/app/app/(app)/[slug]/(agent-builder)/loading.tsx @@ -0,0 +1,24 @@ +import { Skeleton } from "@crm/ui/components/skeleton"; + +export default function AgentBuilderRouteLoading() { + return ( +
+
+ +
+
+
+ +
+ + + +
+
+
+ + Opening chat + +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/companies/companies-table.tsx b/apps/app/app/(app)/[slug]/companies/companies-table.tsx index b8715366..8759377e 100644 --- a/apps/app/app/(app)/[slug]/companies/companies-table.tsx +++ b/apps/app/app/(app)/[slug]/companies/companies-table.tsx @@ -10,19 +10,19 @@ import { EntityLogo, type EntityLogoTone, } from "@crm/ui/components/entity-logo"; -import { relativeTimeFromIso } from "@crm/ui/lib/format"; import { useQuery } from "@tanstack/react-query"; -import { - ENRICHMENT_FACET_OPTIONS, - ENRICHMENT_POLL_MS, - EnrichmentIndicator, - isEnriching, -} from "@/components/crm/enrichment-status"; +import { EnrichmentIndicator } from "@/components/crm/enrichment-status"; import { OwnerCell } from "@/components/crm/owner-cell"; import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { ListSearch } from "@/components/data-table/list-search"; import { useTableQuery } from "@/components/data-table/use-table-query"; +import { LocalRelativeTime } from "@/components/local-date-time"; +import { + ENRICHMENT_FACET_OPTIONS, + ENRICHMENT_POLL_MS, + isEnriching, +} from "@/lib/enrichment-status"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; import { companiesSearchParams } from "./companies-search-params"; @@ -109,8 +109,8 @@ const COLUMNS: DataTableColumn[] = [ width: "w-[10%]", defaultHidden: true, cell: (row) => ( - - {relativeTimeFromIso(row.createdAt)} + + ), }, @@ -122,8 +122,12 @@ const COLUMNS: DataTableColumn[] = [ width: "w-[12%]", hideBelow: "sm", cell: (row) => ( - - {relativeTimeFromIso(row.lastActivityAt)} + + {row.lastActivityAt ? ( + + ) : ( + + )} ), }, diff --git a/apps/app/app/(app)/[slug]/companies/page.tsx b/apps/app/app/(app)/[slug]/companies/page.tsx index ac2a6900..a93f28d1 100644 --- a/apps/app/app/(app)/[slug]/companies/page.tsx +++ b/apps/app/app/(app)/[slug]/companies/page.tsx @@ -50,9 +50,10 @@ export default function CompaniesPage({ async function Companies({ searchParams, }: Pick, "searchParams">) { - await requireSession(); - - const values = await companiesSearchParams.load(searchParams); + const [, values] = await Promise.all([ + requireSession(), + companiesSearchParams.load(searchParams), + ]); const trpc = getServerTrpc(); const queryClient = getServerQueryClient(); diff --git a/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx b/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx index e6148131..e0798db9 100644 --- a/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx +++ b/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx @@ -7,7 +7,6 @@ import { } from "@crm/ui/components/data-table"; import { EmptyCellValue } from "@crm/ui/components/empty-cell"; import { PersonAvatar } from "@crm/ui/components/person-avatar"; -import { relativeTimeFromIso } from "@crm/ui/lib/format"; import { useQuery } from "@tanstack/react-query"; import { CompanyCell } from "@/components/crm/company-cell"; import { contactName } from "@/components/crm/contact-name"; @@ -16,6 +15,7 @@ import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { ListSearch } from "@/components/data-table/list-search"; import { useTableQuery } from "@/components/data-table/use-table-query"; +import { LocalRelativeTime } from "@/components/local-date-time"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; import { contactsSearchParams } from "./contacts-search-params"; @@ -91,8 +91,8 @@ const COLUMNS: DataTableColumn[] = [ width: "w-[10%]", defaultHidden: true, cell: (row) => ( - - {relativeTimeFromIso(row.createdAt)} + + ), }, @@ -104,8 +104,12 @@ const COLUMNS: DataTableColumn[] = [ width: "w-[12%]", hideBelow: "sm", cell: (row) => ( - - {relativeTimeFromIso(row.lastActivityAt)} + + {row.lastActivityAt ? ( + + ) : ( + + )} ), }, diff --git a/apps/app/app/(app)/[slug]/contacts/page.tsx b/apps/app/app/(app)/[slug]/contacts/page.tsx index 6a154079..91570d71 100644 --- a/apps/app/app/(app)/[slug]/contacts/page.tsx +++ b/apps/app/app/(app)/[slug]/contacts/page.tsx @@ -48,9 +48,10 @@ export default function ContactsPage({ async function Contacts({ searchParams, }: Pick, "searchParams">) { - await requireSession(); - - const values = await contactsSearchParams.load(searchParams); + const [, values] = await Promise.all([ + requireSession(), + contactsSearchParams.load(searchParams), + ]); const trpc = getServerTrpc(); const queryClient = getServerQueryClient(); diff --git a/apps/app/app/(app)/[slug]/dashboard-summary.tsx b/apps/app/app/(app)/[slug]/dashboard-summary.tsx index 1ba1a716..f1d5138a 100644 --- a/apps/app/app/(app)/[slug]/dashboard-summary.tsx +++ b/apps/app/app/(app)/[slug]/dashboard-summary.tsx @@ -25,23 +25,18 @@ import { import { Spinner } from "@crm/ui/components/spinner"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { TableCell } from "@crm/ui/components/table"; -import { - formatCount, - formatMoneyCompact, - relativeTimeFromIso, -} from "@crm/ui/lib/format"; +import { formatCount, formatMoneyCompact } from "@crm/ui/lib/format"; import { useMutation, useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useQueryState } from "nuqs"; -import type { CSSProperties } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { toast } from "sonner"; -import { - DealStageIndicator, - dealStageColor, -} from "@/components/crm/deal-stage"; +import { DealStageIndicator } from "@/components/crm/deal-stage"; import { RecordLink } from "@/components/crm/record-sheet/record-link"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; -import { activityLabel } from "@/components/crm/timeline/activity-icon"; +import { LocalRelativeTime } from "@/components/local-date-time"; +import { activityLabel } from "@/lib/activity-presentation"; +import { dealStageColor } from "@/lib/deal-stage"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; @@ -49,6 +44,49 @@ import { overviewParsers } from "./overview-search-params"; import { SalesDashboard } from "./sales-dashboard"; const CELL = "px-3 py-2.5 align-middle"; +const OPEN_COLUMNS: SimpleTableColumn[] = [ + { id: "deal", header: "Deal" }, + { + id: "stage", + header: "Stage", + width: "w-32", + className: "hidden lg:table-cell", + }, + { + id: "share", + srLabel: "Share of the largest", + width: "w-24", + className: "hidden sm:table-cell", + }, + { id: "value", header: "Value", width: "w-20", align: "right" }, +]; +const TASK_COLUMNS: SimpleTableColumn[] = [ + { id: "done", srLabel: "Done", width: "w-8" }, + { id: "task", header: "Task" }, + { id: "overdue", header: "Overdue", width: "w-24", align: "right" }, +]; +const ACTIVITY_COLUMNS: SimpleTableColumn[] = [ + { id: "activity", header: "Activity" }, + { + id: "company", + header: "Company", + width: "w-44", + className: "hidden md:table-cell", + }, + { + id: "deal", + header: "Deal", + width: "w-48", + className: "hidden lg:table-cell", + }, + { + id: "who", + header: "Who", + width: "w-32", + className: "hidden md:table-cell", + }, + { id: "when", header: "When", width: "w-20", align: "right" }, +]; export function DashboardSummary() { const trpc = useTRPC(); @@ -85,31 +123,6 @@ export function DashboardSummary() { const mine = scope === "me"; const largestOpenCents = biggestOpen[0]?.baseAmountCents ?? 0; - const openColumns: SimpleTableColumn[] = [ - { header: "Deal" }, - { header: "Stage", width: "w-32", className: "hidden lg:table-cell" }, - { - srLabel: "Share of the largest", - width: "w-24", - className: "hidden sm:table-cell", - }, - { header: "Value", width: "w-20", align: "right" }, - ]; - - const taskColumns: SimpleTableColumn[] = [ - { srLabel: "Done", width: "w-8" }, - { header: "Task" }, - { header: "Overdue", width: "w-24", align: "right" }, - ]; - - const activityColumns: SimpleTableColumn[] = [ - { header: "Activity" }, - { header: "Company", width: "w-44", className: "hidden md:table-cell" }, - { header: "Deal", width: "w-48", className: "hidden lg:table-cell" }, - { header: "Who", width: "w-32", className: "hidden md:table-cell" }, - { header: "When", width: "w-20", align: "right" }, - ]; - return (
@@ -133,7 +146,11 @@ export function DashboardSummary() { Nothing open. Time to fill the pipeline. ) : ( - + {biggestOpen.map((deal) => ( } />