From b44c47991ccd6d0362a2cfbbc1e1808b47ac0f94 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:12:18 -0700 Subject: [PATCH 1/7] CMP-82 refactor: give cross-package boundary shapes one home in @crm/validation The agent version manifest is written in one package and read in three. Each reader had its own recordOf helper walking the same JSON column, so the stored shape was described four times and agreed on nowhere. It now lives in packages/validation beside the schemas that were already there, with the parse helper that already existed. Every reader consumes that one definition and the private helpers are gone. Trigger config and the CRM event payload move for the same reason: written by the API, read by the agent. Values are imported by subpath rather than the barrel. A value re-export from index.ts trips noBarrelFile, and the subpath keeps the db and slack schemas out of the client bundle. Rows written before this change still load. Where the old code tolerated bad input it still tolerates it, to the same fallback; where it threw it still throws, with the same message. Each path was checked against the original with padded strings, Infinity, NaN, fractional intervals and arrays. The review-version manifest now crosses tRPC parsed rather than raw. That was forced: reading the property off the generated output type raises TS2589, which is why the client had cast through unknown to reach it. Parsing server-side removes both casts and renders identically. --- .oxlintrc.json | 14 +++ AGENTS.md | 5 +- apps/agent/agent/lib/agent-actions.ts | 12 +-- apps/agent/agent/lib/builder-runtime.ts | 3 +- apps/agent/agent/lib/custom-agent-dispatch.ts | 54 ++++-------- apps/agent/agent/lib/run-preflight.ts | 4 +- apps/agent/agent/lib/run-runtime.ts | 65 +++++--------- .../agent_builder/lib/draft-input.ts | 2 +- .../src/agent/agent-definitions.service.ts | 25 +++--- .../conversations/conversations.service.ts | 2 + .../agent-builder/agent-builder-chat.tsx | 51 ++++------- .../agent-builder/team-agent-detail.tsx | 28 ++---- bun.lock | 1 + packages/validation/package.json | 5 +- packages/validation/src/agent-events.ts | 22 +++++ .../validation/src}/agent-manifest.ts | 86 +++++++++++++++++-- packages/validation/src/index.ts | 15 +++- 17 files changed, 227 insertions(+), 167 deletions(-) create mode 100644 packages/validation/src/agent-events.ts rename {apps/agent/agent/lib => packages/validation/src}/agent-manifest.ts (54%) diff --git a/.oxlintrc.json b/.oxlintrc.json index ca8b61ea0..561e3aad9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -41,6 +41,20 @@ "rules": { "anti-slop/no-chained-type-assertions": "off" } + }, + { + "files": [ + "packages/telemetry/src/**", + "apps/api/src/logging/**", + "packages/db/src/fields.ts", + "packages/db/src/fields-shape.ts", + "apps/api/src/fields/**" + ], + "rules": { + "anti-slop/no-unsafe-dictionary-type": "off", + "anti-slop/no-unknown-parameters": "off", + "anti-slop/no-runtime-typeof": "off" + } } ] } diff --git a/AGENTS.md b/AGENTS.md index a5c78b963..2be0b3881 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,10 @@ const slack = manifest.actions.find( const id = slack?.destination.id; ``` -`apps/agent/agent/lib/agent-manifest.ts` is the pattern. Rules that follow from +`packages/validation/src/agent-manifest.ts` is the pattern. A shape that crosses +a package boundary — a `Json` column two apps read, a payload one app writes and +another consumes — lives in `packages/validation/src`, one module per shape, and +is imported by subpath (`@crm/validation/agent-manifest`). Rules that follow from it: - The schema describes what is **actually stored**, not the loosest thing that diff --git a/apps/agent/agent/lib/agent-actions.ts b/apps/agent/agent/lib/agent-actions.ts index a2993a50d..2f8675c59 100644 --- a/apps/agent/agent/lib/agent-actions.ts +++ b/apps/agent/agent/lib/agent-actions.ts @@ -1,11 +1,7 @@ -export const AGENT_ACTION_TYPES = { - CRM_ACTIVITY_CREATE: "crm.activity.create", - RUN_SUMMARY: "run.summary", - SLACK_MESSAGE_POST: "slack.message.post", -} as const; - -export type AgentActionType = - (typeof AGENT_ACTION_TYPES)[keyof typeof AGENT_ACTION_TYPES]; +import { + AGENT_ACTION_TYPES, + type AgentActionType, +} from "@crm/validation/agent-manifest"; export const AGENT_ACTION_EXECUTORS = { [AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE]: "create_crm_activity", diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index 8a20494c0..621e1e852 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -7,7 +7,8 @@ import { } from "@crm/db/crm-events"; import { readAgentModel } from "@crm/db/settings"; import { WORKSPACE_ID } from "@crm/db/workspace"; -import { AGENT_ACTION_TYPES, actionDependency } from "./agent-actions"; +import { AGENT_ACTION_TYPES } from "@crm/validation/agent-manifest"; +import { actionDependency } from "./agent-actions"; import { requestStaleSlackInventorySync } from "./slack-people"; const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; diff --git a/apps/agent/agent/lib/custom-agent-dispatch.ts b/apps/agent/agent/lib/custom-agent-dispatch.ts index e351939f4..a8108706e 100644 --- a/apps/agent/agent/lib/custom-agent-dispatch.ts +++ b/apps/agent/agent/lib/custom-agent-dispatch.ts @@ -1,6 +1,8 @@ import { db, Prisma } from "@crm/db"; -import { CRM_EVENT_CATALOG, isCrmEventType } from "@crm/db/crm-events"; +import { CRM_EVENT_CATALOG } from "@crm/db/crm-events"; import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { crmEventTask } from "@crm/validation/agent-events"; +import { readAgentTriggerConfig } from "@crm/validation/agent-manifest"; import type { SendFn } from "eve/channels"; import { DISPATCH } from "./dispatch-config"; import { DEPENDENCY_UNAVAILABLE, runDependencyFailure } from "./run-preflight"; @@ -219,7 +221,9 @@ export async function queueDueAgentRuns(now = new Date()): Promise { for (const trigger of triggers) { if (!trigger.nextRunAt) continue; const scheduledAt = trigger.nextRunAt; - const intervalMinutes = intervalOf(trigger.config); + const intervalMinutes = readAgentTriggerConfig( + trigger.config, + ).intervalMinutes; const nextRunAt = advance(scheduledAt, intervalMinutes, now); const idempotencyKey = `${trigger.id}:${scheduledAt.toISOString()}`; const claimed = await db.$transaction(async (tx) => { @@ -271,29 +275,22 @@ export async function queueEventAgentRuns( "id" | "contactId" | "companyId" | "dealId" | "payload" >, ): Promise { - const payload = recordOf(task.payload); - const eventType = payload.type; - const record = recordOf(payload.record); - const recordKind = textOf(record.kind); - const recordId = textOf(record.id); - const occurredAt = textOf(payload.occurredAt); + const parsed = crmEventTask.safeParse(task.payload); + if (!parsed.success) { + throw new Error("The queued agent event is invalid."); + } + + const { type: eventType, occurredAt, data } = parsed.data; + const recordKind = CRM_EVENT_CATALOG[eventType].recordKind; + const recordId = parsed.data.record.id; const occurredAtDate = new Date(occurredAt); const taskRecordId = recordKind === "contact" ? task.contactId : recordKind === "company" ? task.companyId - : recordKind === "deal" - ? task.dealId - : null; - if ( - !isCrmEventType(eventType) || - CRM_EVENT_CATALOG[eventType].recordKind !== recordKind || - !recordId || - taskRecordId !== recordId || - !occurredAt || - Number.isNaN(occurredAtDate.getTime()) - ) { + : task.dealId; + if (taskRecordId !== recordId) { throw new Error("The queued agent event is invalid."); } @@ -314,7 +311,7 @@ export async function queueEventAgentRuns( let matched = 0; for (const trigger of triggers) { - if (recordOf(trigger.config).event !== eventType) continue; + if (readAgentTriggerConfig(trigger.config).event !== eventType) continue; const idempotencyKey = `event:${task.id}:trigger:${trigger.id}`; const queued = await db.$transaction(async (tx) => { @@ -340,13 +337,9 @@ export async function queueEventAgentRuns( idempotencyKey, correlationId: `trigger:${trigger.id}:event:${task.id}`, input: { - event: { - type: eventType, - occurredAt, - data: recordOf(payload.data), - }, + event: { type: eventType, occurredAt, data }, record: { kind: recordKind, id: recordId }, - } as Prisma.InputJsonValue, + }, events: { create: { sequence: 0, @@ -883,15 +876,6 @@ function textOf(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } -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( diff --git a/apps/agent/agent/lib/run-preflight.ts b/apps/agent/agent/lib/run-preflight.ts index c1d71468b..ad246c1c4 100644 --- a/apps/agent/agent/lib/run-preflight.ts +++ b/apps/agent/agent/lib/run-preflight.ts @@ -1,10 +1,10 @@ import { db } from "@crm/db"; -import { actionDependency } from "./agent-actions"; import { type AgentManifest, InvalidAgentManifest, parseAgentManifest, -} from "./agent-manifest"; +} from "@crm/validation/agent-manifest"; +import { actionDependency } from "./agent-actions"; import { slackConnected } from "./slack-connection"; export const DEPENDENCY_UNAVAILABLE = "DEPENDENCY_UNAVAILABLE"; diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index 75262e010..c496ff25a 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -2,13 +2,13 @@ import { createHash, randomUUID } from "node:crypto"; import { ActivityType, db, type Prisma } from "@crm/db"; import type { AgentActionStatus, AgentTriggerType } from "@crm/db/enums"; import { lockIdempotencyKey } from "@crm/db/idempotency"; -import { readCompanyHistory, readDealHistory } from "./accounts"; import { - AGENT_ACTION_EXECUTORS, AGENT_ACTION_TYPES, - isAgentActionType, -} from "./agent-actions"; -import { parseAgentManifest } from "./agent-manifest"; + type AgentManifestResource, + parseAgentManifest, +} from "@crm/validation/agent-manifest"; +import { readCompanyHistory, readDealHistory } from "./accounts"; +import { AGENT_ACTION_EXECUTORS, isAgentActionType } from "./agent-actions"; import { readCrmHistory } from "./crm"; import { DISPATCH } from "./dispatch-config"; import { searchCrm } from "./lookup"; @@ -24,12 +24,6 @@ const NO_ACTION_TRIGGER_TYPES = new Set( DISPATCH.run.noActionTriggerTypes, ); -type RunResource = { - kind: "integration" | "company" | "contact" | "deal"; - id: string; - label: string; -}; - type RunRecordScope = "SELECTED" | "WORKSPACE"; type RunActionRow = { @@ -834,7 +828,7 @@ async function requiredActionFailure( }); for (const action of external) { - const type = typeof action.type === "string" ? action.type : "unknown"; + const type = action.type; const rows = recorded.filter((row) => row.type === type); if (rows.some((row) => row.status === "SUCCEEDED")) continue; @@ -857,10 +851,7 @@ async function requiredActionFailure( type, provider: type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST ? "slack" : "crm", - summary: - typeof action.summary === "string" - ? action.summary - : `Perform ${type}`, + summary: action.summary, status: "FAILED", idempotencyKey: `run:${run.id}:required:${type}`, requestHash: hashRequest({ type, required: true }), @@ -930,10 +921,10 @@ async function failLockedRun( function manifestDataScope(value: unknown): { mode: RunRecordScope; - resources: RunResource[]; + resources: AgentManifestResource[]; } { const scope = parseAgentManifest(value).dataScope; - const resources = scope.resources as RunResource[]; + const resources = scope.resources; const records = resources.filter( (resource) => resource.kind !== "integration", ); @@ -962,8 +953,7 @@ function assertActivityAllowed( ) { const allowed = manifestActions(manifest).some( (action) => - action.type === "crm.activity.create" && - Array.isArray(action.activityTypes) && + action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE && action.activityTypes.includes(activityType), ); if (!allowed) { @@ -988,26 +978,17 @@ export function approvedSlackDestination(manifest: unknown): { throw new Error("Agent version does not allow Slack."); } - const destinations = manifestActions(manifest).flatMap((action) => { - if (action.type !== "slack.message.post") return []; - const destination = recordOf(action.destination); - if ( - !["channel", "user"].includes(String(destination.kind)) || - typeof destination.id !== "string" || - !destination.id || - typeof destination.label !== "string" || - !destination.label - ) { - return []; - } - return [ - { - kind: destination.kind as "channel" | "user", - id: destination.id, - label: destination.label, - }, - ]; - }); + const destinations = manifestActions(manifest).flatMap((action) => + action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST + ? [ + { + kind: action.destination.kind, + id: action.destination.id, + label: action.destination.label, + }, + ] + : [], + ); const [destination] = destinations; if (!destination || destinations.length !== 1) { throw new Error( @@ -1020,7 +1001,7 @@ export function approvedSlackDestination(manifest: unknown): { function assertResourceAllowed( mode: RunRecordScope, - resources: RunResource[], + resources: AgentManifestResource[], input: { kind: "contact" | "company" | "deal"; id: string }, ) { if (mode === "WORKSPACE") return; @@ -1039,7 +1020,7 @@ function assertResourceAllowed( ); } -export function allowedHistorySources(resources: RunResource[]): { +export function allowedHistorySources(resources: AgentManifestResource[]): { gmail: boolean; calendar: boolean; } { diff --git a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts index 453e3cf39..67d1d926b 100644 --- a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts +++ b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts @@ -1,6 +1,6 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; +import { AGENT_ACTION_TYPES } from "@crm/validation/agent-manifest"; import { z } from "zod"; -import { AGENT_ACTION_TYPES } from "../../../lib/agent-actions"; import type { DraftAgentInput } from "../../../lib/builder-runtime"; const recordResource = z.object({ diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 9d6521fd8..9c9f0fc97 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -1,6 +1,7 @@ import type { Db, Prisma } from "@crm/db"; import type { AgentDefinitionStatus } from "@crm/db/enums"; import { schemas } from "@crm/validation"; +import { readAgentManifestSummary } from "@crm/validation/agent-manifest"; import { BadRequestException, Injectable, @@ -131,6 +132,7 @@ export class AgentDefinitionsService { if (!row) throw new NotFoundException(`No agent with id ${id}.`); const { versions, ...agent } = row; + const draft = versions[0]; return { ...agent, @@ -144,7 +146,10 @@ export class AgentDefinitionsService { deployedAt: agent.currentVersion.deployedAt?.toISOString() ?? null, } : null, - reviewVersion: agent.status === "DRAFT" ? (versions[0] ?? null) : null, + reviewVersion: + agent.status === "DRAFT" && draft + ? { ...draft, manifest: readAgentManifestSummary(draft.manifest) } + : null, triggers: agent.triggers.map((trigger) => ({ ...trigger, nextRunAt: trigger.nextRunAt?.toISOString() ?? null, @@ -152,7 +157,7 @@ export class AgentDefinitionsService { })), runCount: agent._count.runs, capabilities: readCapabilities( - agent.currentVersion?.manifest ?? versions[0]?.manifest, + agent.currentVersion?.manifest ?? draft?.manifest, ), }; } @@ -793,20 +798,16 @@ export class AgentDefinitionsService { } } -function versionMetadata(manifest: unknown): { +function versionMetadata(manifest: Prisma.JsonValue): { 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 summary = readAgentManifestSummary(manifest); + const name = summary.name?.trim() ?? ""; const description = - typeof record.description === "string" - ? record.description.trim() || null - : undefined; + summary.description === undefined + ? undefined + : summary.description.trim() || null; return { ...(name ? { name } : {}), diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 3004ce885..c1fdce99b 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -1,5 +1,6 @@ import { WORKSPACE_ID } from "@crm/auth"; import { type Db, type Prisma, Prisma as PrismaNamespace } from "@crm/db"; +import { readAgentManifestSummary } from "@crm/validation/agent-manifest"; import { BadRequestException, Injectable, @@ -396,6 +397,7 @@ export class ConversationsService { : null, createdVersions: row.createdVersions.map((version) => ({ ...version, + manifest: readAgentManifestSummary(version.manifest), createdAt: version.createdAt.toISOString(), })), builderArtifacts: row.builderArtifacts.map((artifact) => ({ diff --git a/apps/app/components/agent-builder/agent-builder-chat.tsx b/apps/app/components/agent-builder/agent-builder-chat.tsx index f377fe5f2..6c5897a1c 100644 --- a/apps/app/components/agent-builder/agent-builder-chat.tsx +++ b/apps/app/components/agent-builder/agent-builder-chat.tsx @@ -35,6 +35,7 @@ import { Reasoning } from "@crm/ui/components/reasoning"; import { ThinkingIndicator } from "@crm/ui/components/thinking-indicator"; import { useMountEffect } from "@crm/ui/hooks/use-mount-effect"; import { cn } from "@crm/ui/lib/utils"; +import type { AgentManifestSummary } from "@crm/validation/agent-manifest"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Client, type MessageStreamEvent } from "eve/client"; import type { EveMessage, EveMessageInputRequest } from "eve/react"; @@ -100,12 +101,6 @@ const BUILDER_STEP_ARTIFACTS = [ "agent/manifest.json", "agent/README.md", ] as const; -type DraftVersion = { - id: string; - status: string; - manifest: unknown; -}; - type BuilderSubmission = { id: string; createdAt: string; @@ -1188,11 +1183,11 @@ function ReviewAgentCard({ const workspaceUrl = useWorkspaceUrl(); const version = conversation.createdVersions.find( (candidate) => candidate.id === versionId, - ) as DraftVersion | undefined; + ); const agent = conversation.agent; - const manifest = manifestOf(version?.manifest); if (!version || !agent) return null; + const manifest = manifestOf(version.manifest); return (
@@ -1510,30 +1505,11 @@ function sharedConversationNeedsPolling( return !eventStreamSettled(conversation.events); } -function manifestOf(value: unknown) { - const manifest = recordOf(value); - const triggers = Array.isArray(manifest.triggers) - ? manifest.triggers.map(recordOf) - : []; - const dataScope = recordOf(manifest.dataScope); - const actions = Array.isArray(manifest.actions) - ? manifest.actions.map(recordOf) - : []; - const access = Array.isArray(manifest.access) - ? manifest.access.filter((item): item is string => typeof item === "string") - : []; - +function manifestOf(manifest: AgentManifestSummary) { return { - name: - typeof manifest.name === "string" && manifest.name.trim() - ? manifest.name.trim() - : null, - description: - typeof manifest.description === "string" && manifest.description.trim() - ? manifest.description.trim() - : null, + name: manifest.name?.trim() || null, trigger: - triggers + manifest.triggers .map((trigger) => trigger.type === "MANUAL" ? "On demand" @@ -1543,16 +1519,19 @@ function manifestOf(value: unknown) { ), ) .join(" · ") || "On demand", - looksAt: textOf(dataScope.summary, "CRM records in the approved scope"), + looksAt: textOf( + manifest.dataScope.summary, + "CRM records in the approved scope", + ), action: compactSummary( - actions[0]?.summary, + manifest.actions[0]?.summary, "Perform the requested team action", ), - access, + access: manifest.access, }; } -function compactSummary(value: unknown, fallback: string): string { +function compactSummary(value: string | undefined, fallback: string): string { return textOf(value, fallback).replace(/\s+\([^()]+\)\s*\.?$/, ""); } @@ -1562,6 +1541,6 @@ function recordOf(value: unknown): Record { : {}; } -function textOf(value: unknown, fallback: string): string { - return typeof value === "string" && value.trim() ? value : fallback; +function textOf(value: string | undefined, fallback: string): string { + return value?.trim() ? value : fallback; } diff --git a/apps/app/components/agent-builder/team-agent-detail.tsx b/apps/app/components/agent-builder/team-agent-detail.tsx index e85f0cd39..93cb1f510 100644 --- a/apps/app/components/agent-builder/team-agent-detail.tsx +++ b/apps/app/components/agent-builder/team-agent-detail.tsx @@ -183,22 +183,14 @@ export function TeamAgentDetail({ const data = agent.data ?? initialAgent; const isDraft = data.status === "DRAFT"; - const reviewManifest = recordOf( - ( - data.reviewVersion as unknown as { - manifest: unknown; - } | null - )?.manifest, - ); + const reviewManifest = data.reviewVersion?.manifest; + const fallbackDescription = data.description ?? "A durable team automation."; const displayedName = isDraft - ? textOf(reviewManifest.name, data.name) + ? textOf(reviewManifest?.name, data.name) : data.name; const displayedDescription = isDraft - ? textOf( - reviewManifest.description, - data.description ?? "A durable team automation.", - ) - : (data.description ?? "A durable team automation."); + ? textOf(reviewManifest?.description, fallbackDescription) + : fallbackDescription; const displayedVersionNumber = data.currentVersion?.number ?? data.reviewVersion?.number; const enabledTriggers = data.triggers.filter((trigger) => trigger.enabled); @@ -577,14 +569,8 @@ function _DetailRow({ label, value }: { label: string; value: ReactNode }) { ); } -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function textOf(value: unknown, fallback: string): string { - return typeof value === "string" && value.trim() ? value : fallback; +function textOf(value: string | undefined, fallback: string): string { + return value?.trim() ? value : fallback; } function formatDate(value: string): string { diff --git a/bun.lock b/bun.lock index 83eb5885b..c63e49bc2 100644 --- a/bun.lock +++ b/bun.lock @@ -234,6 +234,7 @@ "name": "@crm/validation", "version": "0.0.0", "dependencies": { + "@crm/db": "workspace:*", "zod": "^4.4.3", }, "devDependencies": { diff --git a/packages/validation/package.json b/packages/validation/package.json index 82b9178c1..5f0bd6b84 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -4,7 +4,9 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./agent-events": "./src/agent-events.ts", + "./agent-manifest": "./src/agent-manifest.ts" }, "scripts": { "check-types": "tsc --noEmit", @@ -13,6 +15,7 @@ "clean": "rm -rf .turbo node_modules" }, "dependencies": { + "@crm/db": "workspace:*", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/validation/src/agent-events.ts b/packages/validation/src/agent-events.ts new file mode 100644 index 000000000..d4ce5bb2f --- /dev/null +++ b/packages/validation/src/agent-events.ts @@ -0,0 +1,22 @@ +import { CRM_EVENT_CATALOG, CRM_EVENT_TYPES } from "@crm/db/crm-events"; +import { z } from "zod"; + +export const crmEventTask = z + .object({ + type: z.enum(CRM_EVENT_TYPES), + record: z.object({ + kind: z.string().trim(), + id: z.string().trim().min(1), + }), + occurredAt: z + .string() + .trim() + .min(1) + .refine((value) => !Number.isNaN(new Date(value).getTime())), + data: z.record(z.string(), z.json()).catch({}), + }) + .refine( + (task) => CRM_EVENT_CATALOG[task.type].recordKind === task.record.kind, + ); + +export type CrmEventTask = z.infer; diff --git a/apps/agent/agent/lib/agent-manifest.ts b/packages/validation/src/agent-manifest.ts similarity index 54% rename from apps/agent/agent/lib/agent-manifest.ts rename to packages/validation/src/agent-manifest.ts index f8d8b661c..9a496e8d1 100644 --- a/apps/agent/agent/lib/agent-manifest.ts +++ b/packages/validation/src/agent-manifest.ts @@ -1,6 +1,20 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; -import { AGENT_ACTION_TYPES } from "./agent-actions"; + +export const AGENT_ACTION_TYPES = { + CRM_ACTIVITY_CREATE: "crm.activity.create", + RUN_SUMMARY: "run.summary", + SLACK_MESSAGE_POST: "slack.message.post", +} as const; + +export type AgentActionType = + (typeof AGENT_ACTION_TYPES)[keyof typeof AGENT_ACTION_TYPES]; + +export const AGENT_TRIGGER_INTERVAL_MINUTES = { + min: 1, + max: 525_600, + fallback: 1_440, +} as const; const slackDestination = z.object({ kind: z.enum(["channel", "user"]), @@ -32,6 +46,15 @@ export const agentManifestAction = z.discriminatedUnion("type", [ }), ]); +export const agentScheduleTriggerConfig = z.object({ + nextRunAt: z.string(), + intervalMinutes: z.number().int().min(AGENT_TRIGGER_INTERVAL_MINUTES.min), +}); + +export const agentEventTriggerConfig = z.object({ + event: z.enum(CRM_EVENT_TYPES), +}); + export const agentManifestTrigger = z.discriminatedUnion("type", [ z.object({ type: z.literal("MANUAL"), @@ -43,16 +66,13 @@ export const agentManifestTrigger = z.discriminatedUnion("type", [ type: z.literal("SCHEDULE"), name: z.string(), summary: z.string(), - config: z.object({ - nextRunAt: z.string(), - intervalMinutes: z.number().int().min(1), - }), + config: agentScheduleTriggerConfig, }), z.object({ type: z.literal("EVENT"), name: z.string(), summary: z.string(), - config: z.object({ event: z.enum(CRM_EVENT_TYPES) }), + config: agentEventTriggerConfig, }), ]); @@ -90,6 +110,7 @@ export const agentManifest = z export type SlackDestination = z.infer; export type AgentManifestAction = z.infer; export type AgentManifestTrigger = z.infer; +export type AgentManifestResource = z.infer; export type AgentManifest = z.infer; export class InvalidAgentManifest extends Error { @@ -109,3 +130,56 @@ export function parseAgentManifest(value: unknown): AgentManifest { .join("; "), ); } + +export const agentTriggerConfig = z.object({ + intervalMinutes: z + .number() + .min(AGENT_TRIGGER_INTERVAL_MINUTES.min) + .transform((minutes) => + Math.min(minutes, AGENT_TRIGGER_INTERVAL_MINUTES.max), + ) + .catch(AGENT_TRIGGER_INTERVAL_MINUTES.fallback), + event: z.enum(CRM_EVENT_TYPES).nullable().catch(null), +}); + +export type AgentTriggerConfig = z.infer; + +const UNREADABLE_TRIGGER_CONFIG: AgentTriggerConfig = { + intervalMinutes: AGENT_TRIGGER_INTERVAL_MINUTES.fallback, + event: null, +}; + +export function readAgentTriggerConfig(value: unknown): AgentTriggerConfig { + const parsed = agentTriggerConfig.safeParse(value); + return parsed.success ? parsed.data : UNREADABLE_TRIGGER_CONFIG; +} + +const optionalText = z.string().optional().catch(undefined); + +export const agentManifestSummary = z.object({ + name: optionalText, + description: optionalText, + access: z + .array(z.string().nullable().catch(null)) + .catch([]) + .transform((entries) => entries.filter((entry) => entry !== null)), + triggers: z + .array(z.object({ type: optionalText, summary: optionalText }).catch({})) + .catch([]), + actions: z.array(z.object({ summary: optionalText }).catch({})).catch([]), + dataScope: z.object({ summary: optionalText }).catch({}), +}); + +export type AgentManifestSummary = z.infer; + +const UNREADABLE_MANIFEST_SUMMARY: AgentManifestSummary = { + access: [], + triggers: [], + actions: [], + dataScope: {}, +}; + +export function readAgentManifestSummary(value: unknown): AgentManifestSummary { + const parsed = agentManifestSummary.safeParse(value); + return parsed.success ? parsed.data : UNREADABLE_MANIFEST_SUMMARY; +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 97e8de82a..0f2481273 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,9 +1,22 @@ import type { ZodType, z } from "zod"; +import * as agentEvents from "./agent-events"; +import * as agentManifest from "./agent-manifest"; import * as agents from "./agents"; import * as slack from "./slack"; -export const schemas = { agents, slack } as const; +export const schemas = { agentEvents, agentManifest, agents, slack } as const; +export type { CrmEventTask } from "./agent-events"; +export type { + AgentActionType, + AgentManifest, + AgentManifestAction, + AgentManifestResource, + AgentManifestSummary, + AgentManifestTrigger, + AgentTriggerConfig, + SlackDestination, +} from "./agent-manifest"; export type { Handoff, HandoffChannel, From 318efc2941a54640919fc71bceaa621b4232c493 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:52:48 -0700 Subject: [PATCH 2/7] CMP-82 refactor: parse vendor and Json boundaries in the agent lib Every vendor response is now parsed where it arrives, so nothing downstream sees the raw shape. LinkedIn, GitHub, Slack and the Context extract each gain a schema at their fetch, and the private str/int and recordOf helpers that stood in for one are gone. The extract schema was itself a Record, which is the thing the boundary was supposed to prevent. It is a JsonSchema now, and the team-page payload is parsed by its owner - the caller supplying the schema is the only code that knows the shape. Prisma Json columns are read as Prisma.JsonValue and parsed with a named schema rather than walked with typeof. Behaviour is unchanged. Every vendor schema catches at each level, so a malformed response still degrades to null or an empty list exactly as the typeof guards did; docs/agent.md requires a missing key to remove a capability and never throw. Where the old code threw it still throws, on the same condition and with the same message. Error helpers keep unknown and are renamed to cause. A catch binding is unknown by language rule and cannot be schema'd; cause is the documented exemption and error-formatter.ts already set that precedent. HOST_ALIASES became a Map, which closes a latent prototype-key hole: http://constructor/ would have stringified Object.prototype.constructor into a canonical value. --- apps/agent/agent/channels/crm.ts | 4 +- apps/agent/agent/lib/accounts.ts | 14 +- apps/agent/agent/lib/agent-actions.ts | 8 +- apps/agent/agent/lib/blank-facts.ts | 5 +- apps/agent/agent/lib/brand-images.ts | 9 +- apps/agent/agent/lib/brand-mapping.ts | 14 +- apps/agent/agent/lib/builder-input.ts | 7 +- apps/agent/agent/lib/builder-runtime.ts | 47 ++-- apps/agent/agent/lib/capabilities.ts | 6 +- apps/agent/agent/lib/context-dev.ts | 110 ++++++---- apps/agent/agent/lib/crm.ts | 8 +- apps/agent/agent/lib/custom-agent-dispatch.ts | 76 ++++--- apps/agent/agent/lib/dispatch.ts | 13 +- apps/agent/agent/lib/enrichment.ts | 2 +- apps/agent/agent/lib/evidence.ts | 4 +- apps/agent/agent/lib/facts.ts | 28 ++- apps/agent/agent/lib/focus.ts | 6 +- apps/agent/agent/lib/linkdapi.ts | 200 ++++++++---------- apps/agent/agent/lib/perplexity.ts | 2 +- apps/agent/agent/lib/portrait-sources.ts | 35 +-- apps/agent/agent/lib/run-preflight.ts | 14 +- apps/agent/agent/lib/run-runtime.ts | 183 ++++++++++------ apps/agent/agent/lib/session-purpose.ts | 21 +- apps/agent/agent/lib/slack-join-task.ts | 5 +- apps/agent/agent/lib/socials.ts | 58 +++-- apps/agent/agent/lib/tasks.ts | 2 +- .../agent_runner/tools/finish_run.ts | 2 +- 27 files changed, 497 insertions(+), 386 deletions(-) diff --git a/apps/agent/agent/channels/crm.ts b/apps/agent/agent/channels/crm.ts index 0128065cf..b8c1920ae 100644 --- a/apps/agent/agent/channels/crm.ts +++ b/apps/agent/agent/channels/crm.ts @@ -26,7 +26,7 @@ import { } from "../lib/dispatch"; import { DISPATCH } from "../lib/dispatch-config"; import { settle } from "../lib/enrichment"; -import { finishRun } from "../lib/run-runtime"; +import { finishRun, runResultOf } from "../lib/run-runtime"; import { attribute } from "../lib/session-purpose"; import { createSlackChannel } from "../lib/slack-membership"; import { completeTask, taskSubject } from "../lib/tasks"; @@ -300,7 +300,7 @@ export default defineChannel({ try { await finishRun(runId, { summary: run.summary ?? "The agent run completed.", - result: recordOf(run.result), + result: runResultOf(run.result), }); } catch (error) { await failRun( diff --git a/apps/agent/agent/lib/accounts.ts b/apps/agent/agent/lib/accounts.ts index 459c39b24..742d37732 100644 --- a/apps/agent/agent/lib/accounts.ts +++ b/apps/agent/agent/lib/accounts.ts @@ -1,8 +1,16 @@ import { ActivityType, db, EmailDirection } from "@crm/db"; +import { z } from "zod"; import { isDerivedName } from "./names"; const BODY_LIMIT = 4000; +const stageChangeMeta = z + .object({ + from: z.string().nullable().catch(null), + to: z.string().nullable().catch(null), + }) + .catch({ from: null, to: null }); + export type AccountThread = { subject: string | null; contact: { id: string; name: string } | null; @@ -487,10 +495,10 @@ export async function readDealHistory( role, })), stageHistory: stageChanges.map((change) => { - const meta = (change.meta ?? {}) as { from?: unknown; to?: unknown }; + const meta = stageChangeMeta.parse(change.meta); return { - from: typeof meta.from === "string" ? meta.from : null, - to: typeof meta.to === "string" ? meta.to : null, + from: meta.from, + to: meta.to, at: change.createdAt.toISOString(), }; }), diff --git a/apps/agent/agent/lib/agent-actions.ts b/apps/agent/agent/lib/agent-actions.ts index 2f8675c59..8f403b4ac 100644 --- a/apps/agent/agent/lib/agent-actions.ts +++ b/apps/agent/agent/lib/agent-actions.ts @@ -9,12 +9,14 @@ export const AGENT_ACTION_EXECUTORS = { [AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: "post_slack_message", } as const satisfies Record; -export function isAgentActionType(value: unknown): value is AgentActionType { - return Object.hasOwn(AGENT_ACTION_EXECUTORS, String(value)); +export function isAgentActionType(value: string): value is AgentActionType { + return Object.hasOwn(AGENT_ACTION_EXECUTORS, value); } +export type AgentActionDependencyId = "slack"; + export type AgentActionDependency = { - readonly id: string; + readonly id: AgentActionDependencyId; readonly label: string; readonly resourceId: string; readonly fix: string; diff --git a/apps/agent/agent/lib/blank-facts.ts b/apps/agent/agent/lib/blank-facts.ts index f05952494..f2088c016 100644 --- a/apps/agent/agent/lib/blank-facts.ts +++ b/apps/agent/agent/lib/blank-facts.ts @@ -79,13 +79,12 @@ export async function sweepBlankFacts( for (const group of groupByField(proposals)) { const [best] = group; const field = best.field as FactField; - const contact = best.contact as FactSubject; + const contact: FactSubject = best.contact; const column = factColumn(field); const current = applied.get(key(best.contactId, field)); if (!fillsBlank({ field, contact, hasAgentFact: current !== undefined })) { - const value = - current ?? (column ? (contact[column] as string | null) : null); + const value = current ?? (column ? contact[column] : null); const stale = redundant(group, value); sweep.waiting += group.length - stale.length; diff --git a/apps/agent/agent/lib/brand-images.ts b/apps/agent/agent/lib/brand-images.ts index f00f2fe57..df017e752 100644 --- a/apps/agent/agent/lib/brand-images.ts +++ b/apps/agent/agent/lib/brand-images.ts @@ -1,6 +1,9 @@ import type { Prisma } from "@crm/db"; import { blobEnabled, mirror } from "@crm/db/blob"; import { COMPANY_IMAGE_FIELDS } from "@crm/db/images"; +import { z } from "zod"; + +const mirrorableUrl = z.string().regex(/\S/).nullable().catch(null); export async function mirrorBrandImages( companyId: string, @@ -12,7 +15,7 @@ export async function mirrorBrandImages( await Promise.all( COMPANY_IMAGE_FIELDS.map(async (slot) => { - const source = plain(update[slot]); + const source = mirrorableUrl.parse(update[slot]); if (!source) return; const stored = await mirror(source, `companies/${companyId}/${slot}`); @@ -25,7 +28,3 @@ export async function mirrorBrandImages( return { update, mirrored }; } - -function plain(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value : null; -} diff --git a/apps/agent/agent/lib/brand-mapping.ts b/apps/agent/agent/lib/brand-mapping.ts index 1d6994d0e..0c92ef8fb 100644 --- a/apps/agent/agent/lib/brand-mapping.ts +++ b/apps/agent/agent/lib/brand-mapping.ts @@ -115,7 +115,7 @@ export function brandToUpdate( value: string | null, ) => { if (value && fillable(key, current)) { - (update as Record)[key] = value; + update[key] = value; } }; @@ -162,15 +162,9 @@ export function stillFillable( update: BrandUpdate, current: CompanySnapshot, ): BrandUpdate { - const next: BrandUpdate = {}; - - for (const [key, value] of Object.entries(update)) { - if (fillable(key, current)) { - (next as Record)[key] = value; - } - } - - return next; + return Object.fromEntries( + Object.entries(update).filter(([key]) => fillable(key, current)), + ); } export function filledFields(update: BrandUpdate): string[] { diff --git a/apps/agent/agent/lib/builder-input.ts b/apps/agent/agent/lib/builder-input.ts index 6e97f263c..a3dbbed7c 100644 --- a/apps/agent/agent/lib/builder-input.ts +++ b/apps/agent/agent/lib/builder-input.ts @@ -1,6 +1,7 @@ import { db, type Prisma } from "@crm/db"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { type InputRequested, parse, schemas } from "@crm/validation"; +import type { ChannelEvents } from "eve/channels"; import { builderIdFromToken, builderToken, @@ -12,8 +13,12 @@ const BUILDER_INPUT = { idPrefix: "builder-input", } as const; +type InputRequestedEvent = Parameters< + NonNullable +>[0]; + export async function persistBuilderInputRequest( - data: unknown, + data: InputRequestedEvent, continuationToken: string | undefined, authenticatedConversationId?: string | null, ): Promise { diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index 621e1e852..4a2a2f9a8 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -8,6 +8,7 @@ import { import { readAgentModel } from "@crm/db/settings"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { AGENT_ACTION_TYPES } from "@crm/validation/agent-manifest"; +import { z } from "zod"; import { actionDependency } from "./agent-actions"; import { requestStaleSlackInventorySync } from "./slack-people"; @@ -20,6 +21,19 @@ export type BuilderResource = { label: string; }; +const taggedResource = z + .object({ + kind: z.enum(["integration", "company", "contact", "deal"]), + id: z.string(), + label: z.string(), + }) + .nullable() + .catch(null); + +const taggedResources = z + .object({ resources: z.array(taggedResource).catch([]) }) + .catch({ resources: [] }); + export type DraftTrigger = { type: "MANUAL" | "SCHEDULE" | "EVENT"; name: string; @@ -72,11 +86,11 @@ export const BUILDER_ARTIFACT_PATHS = [ export type BuilderArtifactPath = (typeof BUILDER_ARTIFACT_PATHS)[number]; -const ARTIFACT_LANGUAGES: Record = { +const ARTIFACT_LANGUAGES = { "agent/README.md": "markdown", "agent/instructions.md": "markdown", "agent/manifest.json": "json", -}; +} satisfies Record; export async function writeBuilderArtifact( conversationId: string, @@ -248,7 +262,7 @@ export async function saveBuilderDraft( credentials: "app-runtime-only", summary: "Isolated sandbox · deny-all network · bounded CRM tools", }; - const files = artifactFiles(input, manifest); + const files = artifactFiles(input, JSON.stringify(manifest, null, 2)); for (const file of files) assertSafeArtifact(file.content); return db.$transaction(async (tx) => { @@ -778,25 +792,10 @@ async function missingResourceIds(resources: BuilderResource[]) { 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 resourcesOf(value: Prisma.JsonValue): BuilderResource[] { + return taggedResources + .parse(value) + .resources.filter((resource) => resource !== null); } function uniqueResources(resources: BuilderResource[]): BuilderResource[] { @@ -816,7 +815,7 @@ function scheduleDate(trigger: DraftTrigger, now: Date): Date | null { return parsed > now ? parsed : null; } -function artifactFiles(input: DraftAgentInput, manifest: object) { +function artifactFiles(input: DraftAgentInput, manifestJson: string) { const triggerSummary = input.triggers .map((trigger) => `- ${trigger.summary}`) .join("\n"); @@ -834,7 +833,7 @@ function artifactFiles(input: DraftAgentInput, manifest: object) { { path: "agent/manifest.json" as const, language: ARTIFACT_LANGUAGES["agent/manifest.json"], - content: `${JSON.stringify(manifest, null, 2)}\n`, + content: `${manifestJson}\n`, }, ]; } diff --git a/apps/agent/agent/lib/capabilities.ts b/apps/agent/agent/lib/capabilities.ts index cef15dcc0..6660bf77d 100644 --- a/apps/agent/agent/lib/capabilities.ts +++ b/apps/agent/agent/lib/capabilities.ts @@ -75,11 +75,13 @@ export async function enabled(id: string): Promise { ); } -export function unavailable(env: string): { +export type UnavailableCapability = { ok: false; configured: false; reason: string; -} { +}; + +export function unavailable(env: string): UnavailableCapability { return { ok: false, configured: false, diff --git a/apps/agent/agent/lib/context-dev.ts b/apps/agent/agent/lib/context-dev.ts index 6f093c43f..cd981fe9d 100644 --- a/apps/agent/agent/lib/context-dev.ts +++ b/apps/agent/agent/lib/context-dev.ts @@ -1,7 +1,25 @@ import ContextDev from "context.dev"; import { APIError } from "context.dev/core/error"; +import { z } from "zod"; import { contextDevKey } from "./capabilities"; +export type JsonSchema = { + type?: + | "array" + | "boolean" + | "integer" + | "null" + | "number" + | "object" + | "string"; + description?: string; + properties?: Record; + items?: JsonSchema; + required?: string[]; + enum?: (string | number | boolean | null)[]; + additionalProperties?: boolean | JsonSchema; +}; + export type Brand = { domain?: string | null; title?: string | null; @@ -99,16 +117,16 @@ export async function verifyKey(key: string): Promise { } } -export function classifyKey(error: unknown): KeyCheck { - if (!(error instanceof APIError)) { - return { outcome: "unknown", reason: describe(error) }; +export function classifyKey(cause: unknown): KeyCheck { + if (!(cause instanceof APIError)) { + return { outcome: "unknown", reason: describe(cause) }; } - if (error.status === undefined) { - return { outcome: "unknown", reason: describe(error) }; + if (cause.status === undefined) { + return { outcome: "unknown", reason: describe(cause) }; } - if (error.status === 401 && !recognisedKeyFailure(error)) { + if (cause.status === 401 && !recognisedKeyFailure(cause)) { return { outcome: "invalid", reason: "Context did not recognise that API key.", @@ -122,12 +140,13 @@ export async function brandByDomain( domain: string, maxAgeMs?: number, ): Promise { - return lookup({ + const params = { type: "by_domain", domain, timeoutMS: TIMEOUT_MS, - ...(maxAgeMs === undefined ? {} : { maxAgeMs }), - }); + } as const; + + return lookup(maxAgeMs === undefined ? params : { ...params, maxAgeMs }); } export async function brandByEmail(email: string): Promise { @@ -145,7 +164,7 @@ export async function prefetch(domain: string): Promise { export async function extract( url: string, - schema: Record, + schema: JsonSchema, instructions: string, ): Promise< { outcome: "found"; data: unknown } | { outcome: "failed"; reason: string } @@ -181,15 +200,18 @@ export async function search( return { outcome: "failed", reason: "Context.dev is not configured." }; } + const params = { + query, + numResults: Math.max(options.limit ?? 10, 10), + markdownOptions: { enabled: true }, + }; + try { - const response = await api.web.search({ - query, - numResults: Math.max(options.limit ?? 10, 10), - markdownOptions: { enabled: true }, - ...(options.excludeDomains - ? { excludeDomains: options.excludeDomains } - : {}), - }); + const response = await api.web.search( + options.excludeDomains + ? { ...params, excludeDomains: options.excludeDomains } + : params, + ); const results = (response.results ?? []).map((result) => ({ url: result.url ?? null, @@ -227,14 +249,14 @@ async function lookup( } } -function classify(error: unknown): LookupResult { - if (!(error instanceof APIError)) { - return { outcome: "failed", reason: describe(error), retryable: true }; +function classify(cause: unknown): LookupResult { + if (!(cause instanceof APIError)) { + return { outcome: "failed", reason: describe(cause), retryable: true }; } - const code = errorCode(error); + const code = errorCode(cause); - if (error.status === 400) { + if (cause.status === 400) { if (code === "NOT_FOUND" || code === "WEBSITE_ACCESS_ERROR") { return { outcome: "skipped", @@ -244,42 +266,46 @@ function classify(error: unknown): LookupResult { : "The site could not be reached.", }; } - return { outcome: "failed", reason: describe(error), retryable: false }; + return { outcome: "failed", reason: describe(cause), retryable: false }; } - if (error.status === 422) { + if (cause.status === 422) { return { outcome: "skipped", reason: "That is a personal or disposable email address.", }; } - if (error.status === 401 || error.status === 403) { - return { outcome: "failed", reason: describe(error), retryable: false }; + if (cause.status === 401 || cause.status === 403) { + return { outcome: "failed", reason: describe(cause), retryable: false }; } - if (error.status === 408 || error.status === 429) { - return { outcome: "failed", reason: describe(error), retryable: true }; + if (cause.status === 408 || cause.status === 429) { + return { outcome: "failed", reason: describe(cause), retryable: true }; } return { outcome: "failed", - reason: describe(error), - retryable: (error.status ?? 500) >= 500, + reason: describe(cause), + retryable: (cause.status ?? 500) >= 500, }; } +const apiErrorBody = z + .object({ + error_code: z.string().optional().catch(undefined), + message: z.string().optional().catch(undefined), + }) + .catch({}); + function errorCode(error: APIError): string | undefined { - const body = error.error as { error_code?: unknown } | undefined; - return typeof body?.error_code === "string" ? body.error_code : undefined; + return apiErrorBody.parse(error.error).error_code; } 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") + const body = apiErrorBody.parse(error.error); + const detail = [body.error_code, body.message, error.message] + .filter((value) => value !== undefined) .join(" "); return /(usage|credit|quota|allowance|billing|rate.?limit|limit.?exceeded|insufficient.?permission)/i.test( @@ -287,9 +313,9 @@ function recognisedKeyFailure(error: APIError): boolean { ); } -function describe(error: unknown): string { - if (error instanceof APIError) { - return `${error.status ?? "?"} ${errorCode(error) ?? error.message}`; +function describe(cause: unknown): string { + if (cause instanceof APIError) { + return `${cause.status ?? "?"} ${errorCode(cause) ?? cause.message}`; } - return error instanceof Error ? error.message : String(error); + return cause instanceof Error ? cause.message : String(cause); } diff --git a/apps/agent/agent/lib/crm.ts b/apps/agent/agent/lib/crm.ts index 91599187f..0c0e5e82e 100644 --- a/apps/agent/agent/lib/crm.ts +++ b/apps/agent/agent/lib/crm.ts @@ -1,4 +1,4 @@ -import { db, EnrichmentStatus } from "@crm/db"; +import { db, EnrichmentStatus, type Prisma } from "@crm/db"; import { domainOf, isDerivedName } from "./names"; import type { Person } from "./socials"; @@ -340,9 +340,7 @@ export async function setEnrichmentStatus( data: { enrichmentStatus: status, enrichmentError: error ?? null, - ...(status === EnrichmentStatus.COMPLETE - ? { enrichedAt: new Date() } - : {}), + enrichedAt: status === EnrichmentStatus.COMPLETE ? new Date() : undefined, }, }); } @@ -351,7 +349,7 @@ export async function writeTimelineNote( contactId: string, subject: string, body: string, - meta: Record = {}, + meta: Prisma.InputJsonObject = {}, ): Promise { const contact = await db.contact.findUnique({ where: { id: contactId }, diff --git a/apps/agent/agent/lib/custom-agent-dispatch.ts b/apps/agent/agent/lib/custom-agent-dispatch.ts index a8108706e..aff74d24e 100644 --- a/apps/agent/agent/lib/custom-agent-dispatch.ts +++ b/apps/agent/agent/lib/custom-agent-dispatch.ts @@ -4,6 +4,7 @@ import { lockIdempotencyKey } from "@crm/db/idempotency"; import { crmEventTask } from "@crm/validation/agent-events"; import { readAgentTriggerConfig } from "@crm/validation/agent-manifest"; import type { SendFn } from "eve/channels"; +import { z } from "zod"; import { DISPATCH } from "./dispatch-config"; import { DEPENDENCY_UNAVAILABLE, runDependencyFailure } from "./run-preflight"; import { @@ -19,6 +20,32 @@ const MAX_BUILDER_ATTEMPTS = DISPATCH.builder.maxAttempts; const BUILDER_LEASE_MS = DISPATCH.builder.leaseMs; const RUN_DELIVERY_LEASE_MS = DISPATCH.run.deliveryLeaseMs; +type BuilderMessageParts = Extract[0], readonly unknown[]>; + +const trimmedText = z.string().trim().catch(""); + +const builderInputResponse = z + .object({ + requestId: trimmedText, + optionId: trimmedText, + text: trimmedText, + }) + .catch({ requestId: "", optionId: "", text: "" }); + +const builderSubmissionMessage = z + .object({ + text: z.string().catch(""), + resources: z + .array(z.object({ label: z.string().catch("") }).catch({ label: "" })) + .catch([]), + inputResponse: builderInputResponse, + }) + .catch({ + text: "", + resources: [], + inputResponse: { requestId: "", optionId: "", text: "" }, + }); + export async function pendingBuilderSubmissionIds(): Promise { await recoverBuilderSubmissions(); const rows = await db.agentConversationSubmission.findMany({ @@ -805,14 +832,11 @@ async function recoverAgentRuns() { export function builderDeliveryMessage( submissionId: string, - value: unknown, + value: Prisma.JsonValue, attachments: readonly BuilderDeliveryAttachment[] = [], ): Parameters[0] { - const message = recordOf(value); - const inputResponse = recordOf(message.inputResponse); - const requestId = textOf(inputResponse.requestId); - const optionId = textOf(inputResponse.optionId); - const responseText = textOf(inputResponse.text); + const message = builderSubmissionMessage.parse(value); + const { requestId, optionId, text: responseText } = message.inputResponse; if (requestId && (optionId || responseText)) { return { inputResponses: [ @@ -824,18 +848,19 @@ export function builderDeliveryMessage( }; } - const text = typeof message.text === "string" ? message.text : ""; - const resources = Array.isArray(message.resources) ? message.resources : []; + const labels = message.resources + .map((resource) => resource.label) + .filter(Boolean); const context = [ `Submission id: ${submissionId}`, - resources.length > 0 - ? `Tagged resources: ${resources.map(resourceLabel).filter(Boolean).join(", ")}` + message.resources.length > 0 + ? `Tagged resources: ${labels.join(", ")}` : null, ] .filter(Boolean) .join("\n"); - const parts: Array> = [ - { type: "text", text: `${context}\n\n${text}` }, + const parts: BuilderMessageParts = [ + { type: "text", text: `${context}\n\n${message.text}` }, ]; for (const attachment of attachments) { @@ -847,18 +872,16 @@ export function builderDeliveryMessage( }); } - return parts as Parameters[0]; + return parts; } export function builderCommandType( commandType: string, - value: unknown, + value: Prisma.JsonValue, ): string { - const inputResponse = recordOf(recordOf(value).inputResponse); - return textOf(inputResponse.requestId) && - (textOf(inputResponse.optionId) || textOf(inputResponse.text)) - ? "CREATE_AGENT" - : commandType; + const { requestId, optionId, text } = + builderSubmissionMessage.parse(value).inputResponse; + return requestId && (optionId || text) ? "CREATE_AGENT" : commandType; } type BuilderDeliveryAttachment = { @@ -867,15 +890,6 @@ type BuilderDeliveryAttachment = { content: Uint8Array; }; -function resourceLabel(value: unknown): string | null { - const row = recordOf(value); - return typeof row.label === "string" ? row.label : null; -} - -function textOf(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - function advance(from: Date, intervalMinutes: number, now: Date): Date { const intervalMs = intervalMinutes * 60_000; const missed = Math.max( @@ -892,9 +906,3 @@ function idFromToken(token: string | undefined, marker: string): string | 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/dispatch.ts b/apps/agent/agent/lib/dispatch.ts index 5f0b9a048..eaf70d22e 100644 --- a/apps/agent/agent/lib/dispatch.ts +++ b/apps/agent/agent/lib/dispatch.ts @@ -252,20 +252,23 @@ export async function linkSession( return false; } -function reasonOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); +function reasonOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); } export function taskAuth(task: LeasedTask, base: AppAuth = APP_AUTH): AppAuth { + const records: Record = {}; + if (task.contactId) records.contactId = task.contactId; + if (task.companyId) records.companyId = task.companyId; + if (task.dealId) records.dealId = task.dealId; + return { ...base, attributes: { taskKind: task.kind, reason: task.reason, budget: String(task.budget), - ...(task.contactId ? { contactId: task.contactId } : {}), - ...(task.companyId ? { companyId: task.companyId } : {}), - ...(task.dealId ? { dealId: task.dealId } : {}), + ...records, }, }; } diff --git a/apps/agent/agent/lib/enrichment.ts b/apps/agent/agent/lib/enrichment.ts index 601a9e5f1..06efad02d 100644 --- a/apps/agent/agent/lib/enrichment.ts +++ b/apps/agent/agent/lib/enrichment.ts @@ -32,7 +32,7 @@ async function write( const data = { enrichmentStatus: status, enrichmentError: error, - ...(status === EnrichmentStatus.COMPLETE ? { enrichedAt: new Date() } : {}), + enrichedAt: status === EnrichmentStatus.COMPLETE ? new Date() : undefined, }; const guard: SettleGuard = onlyIfRunning diff --git a/apps/agent/agent/lib/evidence.ts b/apps/agent/agent/lib/evidence.ts index 67bcb4d3a..fcc3a5a82 100644 --- a/apps/agent/agent/lib/evidence.ts +++ b/apps/agent/agent/lib/evidence.ts @@ -19,7 +19,7 @@ type Weighting = { label: string; }; -export const WEIGHTS: Record = { +export const WEIGHTS = { "profile.email-match": { weight: 0.95, primary: true, @@ -75,7 +75,7 @@ export const WEIGHTS: Record = { primary: false, label: "another source disagrees", }, -}; +} satisfies Record; export type Evidence = { kind: EvidenceKind; diff --git a/apps/agent/agent/lib/facts.ts b/apps/agent/agent/lib/facts.ts index 3daf89890..10eef6992 100644 --- a/apps/agent/agent/lib/facts.ts +++ b/apps/agent/agent/lib/facts.ts @@ -18,15 +18,17 @@ const FIELDS = { export type FactField = keyof typeof FIELDS; +export type FactColumn = NonNullable<(typeof FIELDS)[FactField]["column"]>; + export const FACT_FIELDS = Object.keys(FIELDS) as FactField[]; export type FactSubject = { email: string | null; firstName: string; lastName: string | null; -} & Record; +} & { [Column in FactColumn]: string | null }; -export function factColumn(field: FactField): string | null { +export function factColumn(field: FactField): FactColumn | null { return FIELDS[field].column; } @@ -310,12 +312,8 @@ function humanOwns({ hasAgentFact, }: { field: FactField; - column: string | null; - contact: { - email: string | null; - firstName: string; - lastName: string | null; - } & Record; + column: FactColumn | null; + contact: FactSubject; hasAgentFact: boolean; }): boolean { if (field === "name") { @@ -334,8 +332,8 @@ function isEmpty({ hasAgentFact, }: { field: FactField; - column: string | null; - contact: Record; + column: FactColumn | null; + contact: FactSubject; hasAgentFact: boolean; }): boolean { if (hasAgentFact) return false; @@ -345,10 +343,10 @@ function isEmpty({ return !contact[column]; } -const HOST_ALIASES: Record = { - "twitter.com": "x.com", - "mobile.twitter.com": "x.com", -}; +const HOST_ALIASES = new Map([ + ["twitter.com", "x.com"], + ["mobile.twitter.com", "x.com"], +]); export function sameValue(a: string, b: string): boolean { return canonicalValue(a) === canonicalValue(b); @@ -363,7 +361,7 @@ export function canonicalValue(value: string): string { const host = url.host.replace(/^www\./, ""); const path = url.pathname.replace(/\/+$/, ""); - return `${HOST_ALIASES[host] ?? host}${path}`; + return `${HOST_ALIASES.get(host) ?? host}${path}`; } function asWebUrl(value: string): URL | null { diff --git a/apps/agent/agent/lib/focus.ts b/apps/agent/agent/lib/focus.ts index 439acbd8f..2217c237e 100644 --- a/apps/agent/agent/lib/focus.ts +++ b/apps/agent/agent/lib/focus.ts @@ -10,10 +10,12 @@ export const focus = defineState("crm.focus", () => ({ exhausted: false, })); -export function currentFocus(): { +export type CurrentFocus = { contactId: string | null; sessionId: string | null; -} { +}; + +export function currentFocus(): CurrentFocus { try { const state = focus.get(); return { contactId: state.contactId, sessionId: state.sessionId }; diff --git a/apps/agent/agent/lib/linkdapi.ts b/apps/agent/agent/lib/linkdapi.ts index 1b3be0cd3..aab557d18 100644 --- a/apps/agent/agent/lib/linkdapi.ts +++ b/apps/agent/agent/lib/linkdapi.ts @@ -1,6 +1,33 @@ +import { z } from "zod"; + const HOST = "linkdapi-best-unofficial-linkedin-api.p.rapidapi.com"; const TIMEOUT_MS = 20_000; +const json = z.json(); +const text = z.string().trim().min(1).nullable().catch(null); +const rawText = z.string().nullable().catch(null); +const count = z.number().nullable().catch(null); +const fields = z.record(z.string(), json).catch({}); +const optionalFields = z.record(z.string(), json).nullable().catch(null); +const rows = z.array(json).nullable().catch(null); + +const envelope = z + .object({ + success: z.boolean().nullable().catch(null), + data: json.catch(null), + }) + .catch({ success: null, data: null }); + +const experienceEnvelope = z + .object({ experience: rows, experiences: rows }) + .catch({ experience: null, experiences: null }); + +const companyLookup = z.object({ companies: rows }).catch({ companies: null }); + +type Json = z.infer; + +export type LinkedinFields = z.infer; + export type Profile = { slug: string; profileUrl: string; @@ -64,29 +91,29 @@ export function slugFromProfileUrl(raw: string | null): string | null { } export async function getProfile(slug: string): Promise> { - const result = await call("/api/v1/profile/overview", { - username: slug, - }); + const result = await call("/api/v1/profile/overview", { username: slug }); if (!result.ok) return result; - const d = result.data; + const d = fields.parse(result.data); return { ok: true, data: { slug, profileUrl: `https://www.linkedin.com/in/${slug}`, - fullName: str(d.fullName), - firstName: str(d.firstName), - lastName: str(d.lastName), - headline: str(d.headline), - location: str(d.location), - urn: str(d.urn), - followerCount: int(d.followerCount), - connectionsCount: int(d.connectionsCount), + fullName: text.parse(d.fullName), + firstName: text.parse(d.firstName), + lastName: text.parse(d.lastName), + headline: text.parse(d.headline), + location: text.parse(d.location), + urn: text.parse(d.urn), + followerCount: count.parse(d.followerCount), + connectionsCount: count.parse(d.connectionsCount), photoUrl: profilePhotoUrl(d), - positions: (d.CurrentPositions ?? []).flatMap((p) => - p?.name ? [{ name: p.name, url: str(p.url) }] : [], - ), + positions: (rows.parse(d.CurrentPositions) ?? []).flatMap((entry) => { + const position = fields.parse(entry); + const name = rawText.parse(position.name); + return name ? [{ name, url: text.parse(position.url) }] : []; + }), }, }; } @@ -94,70 +121,73 @@ export async function getProfile(slug: string): Promise> { export async function getExperience( urn: string, ): Promise> { - const result = await call("/api/v1/profile/full-experience", { - urn, - }); + const result = await call("/api/v1/profile/full-experience", { urn }); if (!result.ok) return result; - const payload = result.data; - const rows: RawExperienceRow[] = Array.isArray(payload) - ? payload - : (payload.experience ?? payload.experiences ?? []); + return { ok: true, data: experienceRows(result.data).map(toExperience) }; +} + +function experienceRows(payload: Json): Json[] { + if (Array.isArray(payload)) return payload; + const envelope = experienceEnvelope.parse(payload); + return envelope.experience ?? envelope.experiences ?? []; +} + +function toExperience(row: Json): Experience { + const entry = fields.parse(row); return { - ok: true, - data: rows.map( - (row): Experience => ({ - title: str(row?.title), - company: str(row?.companyName ?? row?.company), - dateRange: str(row?.dateRange ?? row?.duration), - location: str(row?.location), - }), - ), + title: text.parse(entry.title), + company: text.parse(entry.companyName ?? entry.company), + dateRange: text.parse(entry.dateRange ?? entry.duration), + location: text.parse(entry.location), }; } export async function lookupCompany( query: string, ): Promise> { - const result = await call("/api/v1/companies/name-lookup", { - query, - }); + const result = await call("/api/v1/companies/name-lookup", { query }); if (!result.ok) return result; + const companies = companyLookup.parse(result.data).companies ?? []; return { ok: true, - data: (result.data.companies ?? []).flatMap((c) => - c?.id ? [{ id: c.id, displayName: c.displayName ?? c.id }] : [], - ), + data: companies.flatMap((entry) => { + const company = fields.parse(entry); + const id = rawText.parse(company.id); + return id + ? [{ id, displayName: rawText.parse(company.displayName) ?? id }] + : []; + }), }; } export async function getCompany(nameOrId: string): Promise> { const numeric = /^\d+$/.test(nameOrId); - const result = await call("/api/v1/companies/company/info", { + const result = await call("/api/v1/companies/company/info", { [numeric ? "id" : "name"]: nameOrId, }); if (!result.ok) return result; - const d = result.data; + const d = fields.parse(result.data); return { ok: true, data: { - id: str(d.id), - name: str(d.name), - universalName: str(d.universalName), - tagline: str(d.tagline), - description: str(d.description), - linkedinUrl: str(d.linkedinUrl), + id: text.parse(d.id), + name: text.parse(d.name), + universalName: text.parse(d.universalName), + tagline: text.parse(d.tagline), + description: text.parse(d.description), + linkedinUrl: text.parse(d.linkedinUrl), }, }; } -async function call( +async function call( path: string, params: Record, -): Promise> { +): Promise> { const apiKey = key(); if (!apiKey) return { ok: false, missing: false, reason: "No RAPIDAPI_KEY." }; @@ -177,44 +207,29 @@ async function call( return { ok: false, missing: false, reason: `HTTP ${response.status}` }; } - const body = (await response.json()) as { - success?: boolean; - data?: T | null; - }; + const body = envelope.parse(await response.json()); - if (body.success !== true || body.data == null) { + if (body.success !== true || body.data === null) { return { ok: false, missing: true }; } return { ok: true, data: body.data }; - } catch (error) { - const aborted = error instanceof Error && error.name === "AbortError"; + } catch (cause) { + const aborted = cause instanceof Error && cause.name === "AbortError"; return { ok: false, missing: false, reason: aborted ? `Timed out after ${TIMEOUT_MS}ms.` - : error instanceof Error - ? error.message - : String(error), + : cause instanceof Error + ? cause.message + : String(cause), }; } finally { clearTimeout(timer); } } -type RawProfile = { - fullName?: unknown; - firstName?: unknown; - lastName?: unknown; - headline?: unknown; - location?: unknown; - urn?: unknown; - followerCount?: unknown; - connectionsCount?: unknown; - CurrentPositions?: { name?: string; url?: unknown }[] | null; -} & Record; - const PHOTO_KEYS = [ "profilePictureURL", "profilePicture", @@ -229,8 +244,8 @@ const PHOTO_KEYS = [ "avatar", ]; -export function profilePhotoUrl(raw: Record): string | null { - const byLowerKey = new Map(); +export function profilePhotoUrl(raw: LinkedinFields): string | null { + const byLowerKey = new Map(); for (const [key, value] of Object.entries(raw)) { byLowerKey.set(key.toLowerCase(), value); } @@ -251,9 +266,7 @@ export function profilePhotoUrl(raw: Record): string | null { return null; } -function firstUrl(value: unknown): string | null { - if (typeof value === "string") return str(value); - +function firstUrl(value: Json | undefined): string | null { if (Array.isArray(value)) { for (const entry of [...value].reverse()) { const found = firstUrl(entry); @@ -262,43 +275,16 @@ function firstUrl(value: unknown): string | null { return null; } - if (value && typeof value === "object") { - const record = value as Record; + const direct = text.parse(value); + if (direct) return direct; + + const nested = optionalFields.parse(value); + if (nested) { for (const key of ["url", "displayUrl", "src", "large", "original"]) { - const found = firstUrl(record[key]); + const found = firstUrl(nested[key]); if (found) return found; } } return null; } - -type RawExperienceRow = { - title?: unknown; - companyName?: unknown; - company?: unknown; - dateRange?: unknown; - duration?: unknown; - location?: unknown; -}; - -type RawExperience = - | RawExperienceRow[] - | { experience?: RawExperienceRow[]; experiences?: RawExperienceRow[] }; -type RawLookup = { companies?: { id?: string; displayName?: string }[] | null }; -type RawCompany = { - id?: unknown; - name?: unknown; - universalName?: unknown; - tagline?: unknown; - description?: unknown; - linkedinUrl?: unknown; -}; - -function str(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function int(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} diff --git a/apps/agent/agent/lib/perplexity.ts b/apps/agent/agent/lib/perplexity.ts index 4f1c235da..1e5acca4a 100644 --- a/apps/agent/agent/lib/perplexity.ts +++ b/apps/agent/agent/lib/perplexity.ts @@ -44,7 +44,7 @@ export async function ask( : []), { role: "user", content: question }, ], - ...(options.domains ? { search_domain_filter: options.domains } : {}), + search_domain_filter: options.domains, }), }); diff --git a/apps/agent/agent/lib/portrait-sources.ts b/apps/agent/agent/lib/portrait-sources.ts index 269e92e5d..d56ea6f91 100644 --- a/apps/agent/agent/lib/portrait-sources.ts +++ b/apps/agent/agent/lib/portrait-sources.ts @@ -1,4 +1,5 @@ -import { extract } from "./context-dev"; +import { z } from "zod"; +import { extract, type JsonSchema } from "./context-dev"; import { getProfile, slugFromProfileUrl } from "./linkdapi"; import { namesMatch } from "./names"; @@ -71,7 +72,7 @@ export async function findPortrait( return { found: false, tried }; } -const TEAM_SCHEMA = { +const TEAM_SCHEMA: JsonSchema = { type: "object", properties: { people: { @@ -93,6 +94,21 @@ const TEAM_SCHEMA = { required: ["people"], }; +const teamPage = z + .object({ + people: z + .array( + z + .object({ + name: z.string().nullable().catch(null), + photoUrl: z.string().nullable().catch(null), + }) + .catch({ name: null, photoUrl: null }), + ) + .catch([]), + }) + .catch({ people: [] }); + async function fromEmployerSite( subject: PortraitSubject, ): Promise { @@ -106,20 +122,13 @@ async function fromEmployerSite( if (result.outcome !== "found") return null; - const people = (result.data as { people?: unknown } | null)?.people; - if (!Array.isArray(people)) return null; - - for (const entry of people) { - if (!entry || typeof entry !== "object") continue; - const row = entry as Record; - - const name = typeof row.name === "string" ? row.name : null; - const photo = typeof row.photoUrl === "string" ? row.photoUrl : null; - if (!name || !photo) continue; + for (const person of teamPage.parse(result.data).people) { + const { name, photoUrl } = person; + if (!name || !photoUrl) continue; if (!namesMatch(name, subject.name)) continue; try { - const parsed = new URL(photo); + const parsed = new URL(photoUrl); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") continue; return { source: "employer-site", url: parsed.toString() }; } catch {} diff --git a/apps/agent/agent/lib/run-preflight.ts b/apps/agent/agent/lib/run-preflight.ts index ad246c1c4..8a89e34b3 100644 --- a/apps/agent/agent/lib/run-preflight.ts +++ b/apps/agent/agent/lib/run-preflight.ts @@ -4,19 +4,22 @@ import { InvalidAgentManifest, parseAgentManifest, } from "@crm/validation/agent-manifest"; -import { actionDependency } from "./agent-actions"; +import { + type AgentActionDependencyId, + actionDependency, +} from "./agent-actions"; import { slackConnected } from "./slack-connection"; export const DEPENDENCY_UNAVAILABLE = "DEPENDENCY_UNAVAILABLE"; -const CHECKS: Record Promise> = { +const CHECKS = { slack: slackConnected, -}; +} satisfies Record Promise>; export async function missingRunDependencies( manifest: AgentManifest, ): Promise { - const required = new Map(); + const required = new Map(); for (const action of manifest.actions) { const dependency = actionDependency(action.type); @@ -25,8 +28,7 @@ export async function missingRunDependencies( const missing: string[] = []; for (const [id, fix] of required) { - const check = CHECKS[id]; - if (check && !(await check())) missing.push(fix); + if (!(await CHECKS[id]())) missing.push(fix); } return missing; diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index c496ff25a..bc9d9c2f8 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -7,6 +7,7 @@ import { type AgentManifestResource, parseAgentManifest, } from "@crm/validation/agent-manifest"; +import { z } from "zod"; import { readCompanyHistory, readDealHistory } from "./accounts"; import { AGENT_ACTION_EXECUTORS, isAgentActionType } from "./agent-actions"; import { readCrmHistory } from "./crm"; @@ -26,6 +27,63 @@ const NO_ACTION_TRIGGER_TYPES = new Set( type RunRecordScope = "SELECTED" | "WORKSPACE"; +const json = z.json(); + +type Json = z.infer; + +const runResult = z.record(z.string(), json); + +export type RunResult = z.infer; + +const storedRunResult = runResult.catch({}); + +export type SlackRunDestination = { + kind: "channel" | "user"; + id: string; + label: string; +}; + +type RunDataScope = { + mode: RunRecordScope; + resources: AgentManifestResource[]; +}; + +export type RunHistorySources = { + gmail: boolean; + calendar: boolean; +}; + +type SlackRequestBody = Record; + +type HashableRequest = Record; + +const optionalText = z.string().nullable().catch(null); + +const slackActionMetadata = z + .object({ clientMessageId: z.string().min(1).nullable().catch(null) }) + .catch({ clientMessageId: null }); + +const slackEnvelope = z + .object({ ok: z.boolean().nullable().catch(null), error: optionalText }) + .catch({ ok: null, error: null }); + +const slackOpenedConversation = z + .object({ + channel: z + .object({ id: z.string().min(1).nullable().catch(null) }) + .nullable() + .catch(null), + }) + .catch({ channel: null }); + +const slackPostedMessage = z + .object({ channel: optionalText, ts: optionalText }) + .catch({ channel: null, ts: null }); + +const noActionResult = z + .object({ noActionNeeded: optionalText }) + .catch({ noActionNeeded: null }); + type RunActionRow = { id: string; status: AgentActionStatus; @@ -375,8 +433,8 @@ export async function postRunSlackMessage( const { actionId, claimedAt } = claim; try { await assertRunActive(runId); - const clientMessageId = recordOf(claim.metadata).clientMessageId; - if (typeof clientMessageId !== "string" || !clientMessageId) { + const { clientMessageId } = slackActionMetadata.parse(claim.metadata); + if (!clientMessageId) { throw new Error("This Slack action is missing its replay key."); } const accessToken = await slackAccessToken(); @@ -534,7 +592,7 @@ async function failRunAction( export async function sendSlackMessage( accessToken: string, - destination: { kind: "channel" | "user"; id: string; label: string }, + destination: SlackRunDestination, text: string, clientMessageId: string, options: { @@ -546,37 +604,40 @@ export async function sendSlackMessage( const { fetcher = fetch, abortSignal, beforePost } = options; let channel = destination.id; if (destination.kind === "user") { - const opened = await slackApiRequest( - fetcher, - accessToken, - "conversations.open", - { users: destination.id, return_im: true }, - abortSignal, + const opened = slackOpenedConversation.parse( + await slackApiRequest( + fetcher, + accessToken, + "conversations.open", + { users: destination.id, return_im: true }, + abortSignal, + ), ); - const conversation = recordOf(opened.channel); - if (typeof conversation.id !== "string" || !conversation.id) { + if (!opened.channel?.id) { throw new Error("Slack did not return a direct-message channel."); } - channel = conversation.id; + channel = opened.channel.id; } await beforePost?.(); - const data = await slackApiRequest( - fetcher, - accessToken, - "chat.postMessage", - { - channel, - text, - client_msg_id: clientMessageId, - }, - abortSignal, + const posted = slackPostedMessage.parse( + await slackApiRequest( + fetcher, + accessToken, + "chat.postMessage", + { + channel, + text, + client_msg_id: clientMessageId, + }, + abortSignal, + ), ); - if (typeof data.channel !== "string" || typeof data.ts !== "string") { + if (posted.channel === null || posted.ts === null) { throw new Error("Slack returned an incomplete message receipt."); } - return { channel: data.channel, ts: data.ts }; + return { channel: posted.channel, ts: posted.ts }; } async function assertRunActive(runId: string): Promise { @@ -635,9 +696,9 @@ async function slackApiRequest( fetcher: typeof fetch, accessToken: string, method: string, - body: Record, + body: SlackRequestBody, abortSignal?: AbortSignal, -): Promise> { +): Promise { const response = await fetcher(`https://slack.com/api/${method}`, { method: "POST", headers: { @@ -649,9 +710,10 @@ async function slackApiRequest( }); if (!response.ok) throw new Error("Slack message delivery failed."); - const data = recordOf(await response.json()); - if (data.ok !== true) { - const reason = typeof data.error === "string" ? data.error : "rejected"; + const data = json.catch(null).parse(await response.json()); + const envelope = slackEnvelope.parse(data); + if (envelope.ok !== true) { + const reason = envelope.error ?? "rejected"; if (reason === "not_in_channel") { throw new Error( "The Slack bot is not in the selected channel. Invite the app to that channel and retry the run.", @@ -678,7 +740,7 @@ export async function stageRunResult( runId: string, input: { summary: string; - result?: Record | null; + result?: RunResult | null; noActionNeeded?: { reason: string } | null; }, ) { @@ -692,12 +754,10 @@ export async function stageRunResult( if (refusal) throw new Error(refusal); } - const result = { - ...(input.result ?? {}), - ...(input.noActionNeeded - ? { noActionNeeded: input.noActionNeeded.reason } - : {}), - }; + const result: RunResult = { ...(input.result ?? {}) }; + if (input.noActionNeeded) { + result.noActionNeeded = input.noActionNeeded.reason; + } await tx.agentRun.update({ where: { id: runId }, @@ -711,18 +771,19 @@ export async function stageRunResult( }); } -export function runReportedNoActionNeeded(result: unknown): boolean { - return ( - typeof result === "object" && - result !== null && - !Array.isArray(result) && - typeof (result as Record).noActionNeeded === "string" - ); +export function runResultOf(value: Prisma.JsonValue): RunResult { + return storedRunResult.parse(value); +} + +export function runReportedNoActionNeeded( + result: RunResult | null | undefined, +): boolean { + return noActionResult.parse(result).noActionNeeded !== null; } export async function finishRun( runId: string, - input: { summary: string; result?: Record | null }, + input: { summary: string; result?: RunResult | null }, ) { return db.$transaction(async (tx) => { const run = await lockAgentRun(tx, runId); @@ -919,10 +980,7 @@ async function failLockedRun( return { id: run.id, status: "FAILED" as const }; } -function manifestDataScope(value: unknown): { - mode: RunRecordScope; - resources: AgentManifestResource[]; -} { +function manifestDataScope(value: Prisma.JsonValue): RunDataScope { const scope = parseAgentManifest(value).dataScope; const resources = scope.resources; const records = resources.filter( @@ -937,18 +995,18 @@ function manifestDataScope(value: unknown): { return { mode: scope.mode, resources }; } -function manifestActions(value: unknown) { +function manifestActions(value: Prisma.JsonValue) { return parseAgentManifest(value).actions; } -function externalManifestActions(value: unknown) { +function externalManifestActions(value: Prisma.JsonValue) { return manifestActions(value).filter( (action) => action.type !== AGENT_ACTION_TYPES.RUN_SUMMARY, ); } function assertActivityAllowed( - manifest: unknown, + manifest: Prisma.JsonValue, activityType: "NOTE" | "TASK", ) { const allowed = manifestActions(manifest).some( @@ -963,11 +1021,9 @@ function assertActivityAllowed( } } -export function approvedSlackDestination(manifest: unknown): { - kind: "channel" | "user"; - id: string; - label: string; -} { +export function approvedSlackDestination( + manifest: Prisma.JsonValue, +): SlackRunDestination { const scope = manifestDataScope(manifest); if ( !scope.resources.some( @@ -1020,10 +1076,9 @@ function assertResourceAllowed( ); } -export function allowedHistorySources(resources: AgentManifestResource[]): { - gmail: boolean; - calendar: boolean; -} { +export function allowedHistorySources( + resources: AgentManifestResource[], +): RunHistorySources { const integrations = new Set( resources .filter((resource) => resource.kind === "integration") @@ -1081,12 +1136,6 @@ async function targetRecord(kind: "company" | "contact" | "deal", id: string) { : 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"; @@ -1105,7 +1154,7 @@ function actionRequestHash(input: { }); } -function hashRequest(input: Record): string { +function hashRequest(input: HashableRequest): string { return createHash("sha256").update(JSON.stringify(input)).digest("hex"); } diff --git a/apps/agent/agent/lib/session-purpose.ts b/apps/agent/agent/lib/session-purpose.ts index 2b85f12d1..f3b292881 100644 --- a/apps/agent/agent/lib/session-purpose.ts +++ b/apps/agent/agent/lib/session-purpose.ts @@ -1,18 +1,24 @@ +import { z } from "zod"; + export type SessionPurpose = "builder" | "team-agent" | "research"; +type SessionAttributes = Readonly>; + type PurposeContext = { readonly session: { readonly auth: { readonly current: { - readonly attributes: Readonly>; + readonly attributes: SessionAttributes; } | null; readonly initiator: { - readonly attributes: Readonly>; + readonly attributes: SessionAttributes; } | null; }; }; }; +const attributeText = z.string().trim().min(1).nullable().catch(null); + export function purposeOf(ctx: PurposeContext): SessionPurpose { const purpose = attribute(ctx, "purpose"); if (purpose === "builder" || purpose === "team-agent") return purpose; @@ -20,13 +26,10 @@ export function purposeOf(ctx: PurposeContext): SessionPurpose { } 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; + return ( + attributeText.parse(ctx.session.auth.current?.attributes[key]) ?? + attributeText.parse(ctx.session.auth.initiator?.attributes[key]) + ); } export function requireAttribute(ctx: PurposeContext, key: string): string { diff --git a/apps/agent/agent/lib/slack-join-task.ts b/apps/agent/agent/lib/slack-join-task.ts index b2482bab3..27d29c653 100644 --- a/apps/agent/agent/lib/slack-join-task.ts +++ b/apps/agent/agent/lib/slack-join-task.ts @@ -1,7 +1,10 @@ +import type { Prisma } from "@crm/db"; import { parse, schemas } from "@crm/validation"; import { joinSlackChannel } from "./slack-membership"; -export async function runSlackChannelJoin(value: unknown): Promise { +export async function runSlackChannelJoin( + value: Prisma.JsonValue, +): Promise { const { channelId, channelName } = parse( schemas.slack.joinPayload, value, diff --git a/apps/agent/agent/lib/socials.ts b/apps/agent/agent/lib/socials.ts index 2364ebd5a..a59e5906c 100644 --- a/apps/agent/agent/lib/socials.ts +++ b/apps/agent/agent/lib/socials.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { Evidence } from "./evidence"; import { looksLikeSameCompany, @@ -6,6 +7,27 @@ import { } from "./names"; import { ask } from "./perplexity"; +const text = z.string().trim().min(1).nullable().catch(null); +const rawText = z.string().nullable().catch(null); + +const githubAccount = z + .object({ + login: rawText, + name: text, + company: text, + blog: text, + bio: text, + type: rawText, + }) + .catch({ + login: null, + name: null, + company: null, + blog: null, + bio: null, + type: null, + }); + export type Network = "x" | "github"; export type SocialProfile = { @@ -159,18 +181,16 @@ async function fetchGithubUser( handle: string, ): Promise<{ ok: true; user: GithubUser } | { ok: false; reason: string }> { const token = process.env.GITHUB_TOKEN; + const headers = new Headers({ + accept: "application/vnd.github+json", + "user-agent": "comp-ai-crm-research-agent", + }); + if (token) headers.set("authorization", `Bearer ${token}`); try { const response = await fetch( `https://api.github.com/users/${encodeURIComponent(handle)}`, - { - headers: { - accept: "application/vnd.github+json", - "user-agent": "comp-ai-crm-research-agent", - ...(token ? { authorization: `Bearer ${token}` } : {}), - }, - signal: AbortSignal.timeout(15_000), - }, + { headers, signal: AbortSignal.timeout(15_000) }, ); if (response.status === 404) { @@ -186,23 +206,23 @@ async function fetchGithubUser( return { ok: false, reason: `GitHub returned HTTP ${response.status}.` }; } - const body = (await response.json()) as Record; + const account = githubAccount.parse(await response.json()); return { ok: true, user: { - login: String(body.login ?? handle), - name: str(body.name), - company: str(body.company), - blog: str(body.blog), - bio: str(body.bio), - type: String(body.type ?? "User"), + login: account.login ?? handle, + name: account.name, + company: account.company, + blog: account.blog, + bio: account.bio, + type: account.type ?? "User", }, }; - } catch (error) { + } catch (cause) { return { ok: false, - reason: error instanceof Error ? error.message : String(error), + reason: cause instanceof Error ? cause.message : String(cause), }; } } @@ -396,7 +416,3 @@ export async function findSocialCandidates( return { candidates, citations: answer.data.citations }; } - -function str(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} diff --git a/apps/agent/agent/lib/tasks.ts b/apps/agent/agent/lib/tasks.ts index 89ab2d518..a29637f98 100644 --- a/apps/agent/agent/lib/tasks.ts +++ b/apps/agent/agent/lib/tasks.ts @@ -94,7 +94,7 @@ export async function completeTask( data: { finishedAt: new Date(), outcome: outcome.slice(0, 500), - ...(sessionId ? { sessionId } : {}), + sessionId: sessionId || undefined, }, }); diff --git a/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts b/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts index f182fa00a..57e301438 100644 --- a/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts +++ b/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts @@ -8,7 +8,7 @@ export default defineTool({ "Finish this run successfully with its concise summary and structured result. Set noActionNeeded when the trigger fired but this run's condition was not met, so none of the declared actions applied — an agent that watches for something is expected to do nothing when that thing did not happen.", inputSchema: z.object({ summary: z.string().trim().min(1).max(1000), - result: z.record(z.string(), z.unknown()).nullish(), + result: z.record(z.string(), z.json()).nullish(), noActionNeeded: z .object({ reason: z.string().trim().min(1).max(500), From 784c5a6cb89f4c07db51a784cfe457c7a306c557 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:05:26 -0700 Subject: [PATCH 3/7] CMP-82 refactor: parse eve events and tool results once for both apps The transcript in the app and the conversation service in the API read the same eve data - stream events, message parts, tool input and output - and each walked it with its own recordOf helper. The shapes now live in packages/validation beside the manifest, and both consume them. Degradation is preserved exactly: every schema catches, so a malformed part is still ignored rather than throwing, which docs/agent.md requires of a panel read. Empty-string codes and reasons still take the same paths they did. --- .../conversations/conversation-attachments.ts | 14 +- .../conversations/conversations.service.ts | 86 +----- apps/app/lib/activity-presentation.ts | 24 +- apps/app/lib/agent-bridge.ts | 43 ++- apps/app/lib/agent-builder-state.ts | 35 +-- apps/app/lib/agent-record.ts | 20 +- apps/app/lib/agent-results.ts | 3 +- apps/app/lib/agent-run-failure.ts | 4 +- apps/app/lib/agent-tool-display.ts | 27 +- apps/app/lib/agent-transcript.ts | 261 +++++++----------- apps/app/lib/deal-stage.ts | 7 +- apps/app/lib/enrichment-status.ts | 6 +- apps/app/lib/onboarding.ts | 50 ++-- apps/app/lib/social-links.ts | 7 +- apps/app/lib/trpc/query-client.ts | 20 +- apps/app/package.json | 3 +- apps/app/test/agent-results.spec.ts | 2 +- bun.lock | 1 + packages/validation/package.json | 5 +- packages/validation/src/builder-question.ts | 43 +++ packages/validation/src/eve-stream.ts | 49 ++++ packages/validation/src/eve-tool.ts | 40 +++ packages/validation/src/index.ts | 30 +- 23 files changed, 443 insertions(+), 337 deletions(-) create mode 100644 packages/validation/src/builder-question.ts create mode 100644 packages/validation/src/eve-stream.ts create mode 100644 packages/validation/src/eve-tool.ts diff --git a/apps/api/src/conversations/conversation-attachments.ts b/apps/api/src/conversations/conversation-attachments.ts index 4dc8d4fab..9eacc3758 100644 --- a/apps/api/src/conversations/conversation-attachments.ts +++ b/apps/api/src/conversations/conversation-attachments.ts @@ -1,4 +1,7 @@ import type { Prisma } from "@crm/db"; +import { z } from "zod"; + +const builderMessageFields = z.record(z.string(), z.json()).catch({}); export type StoredBuilderAttachment = { id: string; @@ -12,9 +15,8 @@ export function builderMessageWithAttachments( attachments: StoredBuilderAttachment[], shareToken?: string, ): Prisma.JsonObject { - const message = recordOf(value); return { - ...message, + ...builderMessageFields.parse(value), attachments: attachments.map((attachment) => ({ id: attachment.id, name: attachment.name, @@ -24,7 +26,7 @@ export function builderMessageWithAttachments( ? attachmentUrl(attachment.id, shareToken) : null, })), - } as Prisma.JsonObject; + }; } export function isPreviewableImage(mediaType: string): boolean { @@ -37,9 +39,3 @@ 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/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index c1fdce99b..4aeba3c6c 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -1,6 +1,10 @@ import { WORKSPACE_ID } from "@crm/auth"; import { type Db, type Prisma, Prisma as PrismaNamespace } from "@crm/db"; import { readAgentManifestSummary } from "@crm/validation/agent-manifest"; +import { + type BuilderQuestion, + builderQuestion, +} from "@crm/validation/builder-question"; import { BadRequestException, Injectable, @@ -52,6 +56,10 @@ export interface BuilderConversationSummary { } | null; } +type ReplayedRequest = { + id: string; +}; + type ExistingBuilderRequest = { id: string; conversationId: string; @@ -587,8 +595,7 @@ export class ConversationsService { throw new BadRequestException("Choose an answer before submitting."); } - const displayText = - typeof selected?.label === "string" ? selected.label : answer; + const displayText = selected?.label ?? answer; const inputResponse = input.optionId ? { requestId: input.requestId, optionId: input.optionId } : { requestId: input.requestId, text: input.text }; @@ -1084,7 +1091,7 @@ export class ConversationsService { private replayBuilderCreation( existing: ExistingBuilderRequest, userId: string, - ): { id: string } { + ): ReplayedRequest { if ( existing.conversation.userId !== userId || existing.conversation.kind !== "BUILDER" @@ -1099,7 +1106,7 @@ export class ConversationsService { existing: ExistingBuilderRequest, conversationId: string, userId: string, - ): { id: string } { + ): ReplayedRequest { if ( existing.conversationId !== conversationId || existing.submittedById !== userId @@ -1118,71 +1125,8 @@ function isUniqueConstraint(error: unknown): boolean { ); } -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 : []; -} - -function pendingBuilderQuestionOf(value: unknown) { - const request = recordOf(value); - if ( - request.kind !== "question" || - typeof request.requestId !== "string" || - !request.requestId || - typeof request.prompt !== "string" || - !request.prompt - ) { - return null; - } - - const display = ["confirmation", "select", "text"].includes( - String(request.display), - ) - ? (request.display as "confirmation" | "select" | "text") - : undefined; - const options = arrayOf(request.options).flatMap((value) => { - const option = recordOf(value); - if ( - typeof option.id !== "string" || - !option.id || - typeof option.label !== "string" || - !option.label - ) { - return []; - } - const style = ["danger", "default", "primary"].includes( - String(option.style), - ) - ? (option.style as "danger" | "default" | "primary") - : undefined; - - return [ - { - id: option.id, - label: option.label, - description: - typeof option.description === "string" - ? option.description - : undefined, - style, - }, - ]; - }); - - return { - kind: "question" as const, - requestId: request.requestId, - prompt: request.prompt, - display, - options, - allowFreeform: - request.allowFreeform === true || - display === "text" || - options.length === 0, - }; +function pendingBuilderQuestionOf( + value: Prisma.JsonValue, +): BuilderQuestion | null { + return builderQuestion.parse(value); } diff --git a/apps/app/lib/activity-presentation.ts b/apps/app/lib/activity-presentation.ts index 47d044456..3f199188e 100644 --- a/apps/app/lib/activity-presentation.ts +++ b/apps/app/lib/activity-presentation.ts @@ -8,16 +8,20 @@ import Task from "@carbon/icons-react/es/Task"; import type { ActivityType } from "@crm/db/enums"; import type { CarbonIcon } from "@crm/ui/components/icon"; -const PRESENTATION: Record = - { - NOTE: { icon: Chat, label: "Note" }, - CALL: { icon: Phone, label: "Call" }, - EMAIL: { icon: Email, label: "Email" }, - MEETING: { icon: Events, label: "Meeting" }, - TASK: { icon: Task, label: "Task" }, - STAGE_CHANGE: { icon: ArrowRight, label: "Stage change" }, - ENRICHMENT: { icon: MagicWand, label: "Enrichment" }, - }; +type ActivityPresentation = Record< + ActivityType, + { icon: CarbonIcon; label: string } +>; + +const PRESENTATION: ActivityPresentation = { + NOTE: { icon: Chat, label: "Note" }, + CALL: { icon: Phone, label: "Call" }, + EMAIL: { icon: Email, label: "Email" }, + MEETING: { icon: Events, label: "Meeting" }, + TASK: { icon: Task, label: "Task" }, + STAGE_CHANGE: { icon: ArrowRight, label: "Stage change" }, + ENRICHMENT: { icon: MagicWand, label: "Enrichment" }, +}; export function activityLabel(type: ActivityType): string { return PRESENTATION[type].label; diff --git a/apps/app/lib/agent-bridge.ts b/apps/app/lib/agent-bridge.ts index 699cb5b49..9b57449b2 100644 --- a/apps/app/lib/agent-bridge.ts +++ b/apps/app/lib/agent-bridge.ts @@ -9,6 +9,20 @@ export function bridgeConfigured(): boolean { return Boolean(process.env.AGENT_BRIDGE_SECRET); } +type BridgeClaims = { + iss: string; + aud: string; + sub: string; + email: string; + name: string; + contactId?: string; + companyId?: string; + dealId?: string; + iat: number; + nbf: number; + exp: number; +}; + export async function mintBridgeToken( user: { id: string; @@ -23,44 +37,43 @@ export async function mintBridgeToken( const now = Math.floor(Date.now() / 1000); const header = { alg: "HS256", typ: "JWT" }; - const payload = { + const payload: BridgeClaims = { iss: ISSUER, aud: AUDIENCE, sub: user.id, email: user.email, name: user.name, - ...(record.contactId ? { contactId: record.contactId } : {}), - ...(record.companyId ? { companyId: record.companyId } : {}), - ...(record.dealId ? { dealId: record.dealId } : {}), iat: now, nbf: now - 5, exp: now + TTL_SECONDS, }; - const signingInput = `${base64url(JSON.stringify(header))}.${base64url( - JSON.stringify(payload), + if (record.contactId) payload.contactId = record.contactId; + if (record.companyId) payload.companyId = record.companyId; + if (record.dealId) payload.dealId = record.dealId; + + const signingInput = `${base64url(encode(JSON.stringify(header)))}.${base64url( + encode(JSON.stringify(payload)), )}`; const key = await crypto.subtle.importKey( "raw", - new TextEncoder().encode(secret), + encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); - const signature = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(signingInput), - ); + const signature = await crypto.subtle.sign("HMAC", key, encode(signingInput)); return `${signingInput}.${base64url(new Uint8Array(signature))}`; } -function base64url(input: string | Uint8Array): string { - const bytes = - typeof input === "string" ? new TextEncoder().encode(input) : input; +function encode(input: string) { + return new TextEncoder().encode(input); +} + +function base64url(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary) diff --git a/apps/app/lib/agent-builder-state.ts b/apps/app/lib/agent-builder-state.ts index 99a0c388d..2fddb79f3 100644 --- a/apps/app/lib/agent-builder-state.ts +++ b/apps/app/lib/agent-builder-state.ts @@ -1,3 +1,9 @@ +import { + type EveStreamEvent, + eveRequestedActions, + eveSettledAction, +} from "@crm/validation/eve-stream"; + export type BuilderArtifactState = { id: string; versionId: string | null; @@ -59,20 +65,18 @@ export function builderSessionStreamKey( } export function agentBuilderCallIsActive( - events: readonly { type: string; data?: unknown }[], + events: readonly EveStreamEvent[], ): boolean { const activeCallIds = new Set(); for (const event of events) { - const data = recordOf(event.data); if (event.type === "actions.requested") { - for (const value of arrayOf(data.actions)) { - const action = recordOf(value); + for (const action of eveRequestedActions.parse(event.data).actions) { if ( action.kind === "subagent-call" && (action.name === "agent_builder" || action.subagentName === "agent_builder") && - typeof action.callId === "string" + action.callId !== null ) { activeCallIds.add(action.callId); } @@ -80,11 +84,12 @@ export function agentBuilderCallIsActive( } if (event.type === "action.result" || event.type === "subagent.completed") { - const result = recordOf(data.result); - const action = recordOf(data.action); - const callId = [data.callId, result.callId, action.callId].find( - (value): value is string => typeof value === "string", - ); + const settled = eveSettledAction.parse(event.data); + const callId = [ + settled.callId, + settled.result.callId, + settled.action.callId, + ].find((value) => value !== null); if (callId) activeCallIds.delete(callId); } @@ -151,13 +156,3 @@ export function latestCompletedArtifactVersionId( )?.versionId ?? null ); } - -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/app/lib/agent-record.ts b/apps/app/lib/agent-record.ts index f3d17a410..c4e216cc4 100644 --- a/apps/app/lib/agent-record.ts +++ b/apps/app/lib/agent-record.ts @@ -13,7 +13,17 @@ type RecordCopy = { suggestions: string[]; }; -const COPY: Record = { +type RecordCopyByKind = Record; + +export type AgentRecordHeader = Record; + +export type AgentRecordFilter = { + contactId?: string; + companyId?: string; + dealId?: string; +}; + +const COPY: RecordCopyByKind = { contact: { header: "x-crm-contact", field: "contactId", @@ -59,15 +69,11 @@ export function recordCopy(kind: AgentRecordKind): RecordCopy { return COPY[kind]; } -export function recordHeader(record: AgentRecord): Record { +export function recordHeader(record: AgentRecord): AgentRecordHeader { return { [COPY[record.kind].header]: record.id }; } -export function recordFilter(record: AgentRecord): { - contactId?: string; - companyId?: string; - dealId?: string; -} { +export function recordFilter(record: AgentRecord): AgentRecordFilter { return { [COPY[record.kind].field]: record.id }; } diff --git a/apps/app/lib/agent-results.ts b/apps/app/lib/agent-results.ts index b70410b4a..279cf6f56 100644 --- a/apps/app/lib/agent-results.ts +++ b/apps/app/lib/agent-results.ts @@ -1,3 +1,4 @@ +import type { EveToolOutput } from "@crm/validation/eve-tool"; import type { TranscriptItem } from "./agent-transcript"; export type AnchoredResult = { itemId: string; value: T }; @@ -10,7 +11,7 @@ export function anchorResults({ }: { items: readonly TranscriptItem[]; tool: string; - validate: (output: unknown) => T | null; + validate: (output: EveToolOutput) => T | null; group?: ( results: readonly AnchoredResult[], ) => readonly AnchoredResult[]; diff --git a/apps/app/lib/agent-run-failure.ts b/apps/app/lib/agent-run-failure.ts index 390732b7a..23c65ae93 100644 --- a/apps/app/lib/agent-run-failure.ts +++ b/apps/app/lib/agent-run-failure.ts @@ -1,4 +1,6 @@ -const REASONS: Record = { +type RunFailureReasons = Record; + +const REASONS: RunFailureReasons = { ACTION_NOT_PERFORMED: "The agent finished without doing what it was built to do. Open the run to see which step it skipped.", NO_EXECUTOR: diff --git a/apps/app/lib/agent-tool-display.ts b/apps/app/lib/agent-tool-display.ts index 90016b5b5..9c83394c8 100644 --- a/apps/app/lib/agent-tool-display.ts +++ b/apps/app/lib/agent-tool-display.ts @@ -1,4 +1,12 @@ -const ARTIFACT_NAMES: Record = { +import { + type EveToolFields, + type EveToolInput, + eveToolText, +} from "@crm/validation/eve-tool"; + +type ArtifactNames = Record; + +const ARTIFACT_NAMES: ArtifactNames = { "agent/instructions.md": "instructions", "agent/manifest.json": "the manifest", "agent/README.md": "the readme", @@ -6,28 +14,29 @@ const ARTIFACT_NAMES: Record = { type LabelInput = { tool: string; - input: Record | null; + input: EveToolInput; label: string; pending: boolean; }; -const INPUT_LABELS: Record< - string, - (input: Record, pending: boolean) => string | null -> = { +type ToolInputLabel = (input: EveToolFields, pending: boolean) => string | null; + +type ToolInputLabels = Record; + +const INPUT_LABELS: ToolInputLabels = { write_agent_file: (input, pending) => { - const path = typeof input.path === "string" ? input.path : null; + const path = eveToolText.parse(input.path); if (!path) return null; const name = ARTIFACT_NAMES[path] ?? path; return pending ? `Writing ${name}` : `Wrote ${name}`; }, save_agent_draft: (input, pending) => { - const name = typeof input.name === "string" ? input.name.trim() : ""; + const name = eveToolText.parse(input.name).trim(); const verb = pending ? "Saving draft" : "Saved draft"; return name ? `${verb} · ${name}` : verb; }, set_chat_title: (input, pending) => { - const title = typeof input.title === "string" ? input.title.trim() : ""; + const title = eveToolText.parse(input.title).trim(); const verb = pending ? "Naming this chat" : "Named this chat"; return title ? `${verb} · ${title}` : verb; }, diff --git a/apps/app/lib/agent-transcript.ts b/apps/app/lib/agent-transcript.ts index 8c8ef744f..af1e26c33 100644 --- a/apps/app/lib/agent-transcript.ts +++ b/apps/app/lib/agent-transcript.ts @@ -1,3 +1,16 @@ +import { + type EveStreamEvent, + eveTurnFailure, + eveTurnReference, +} from "@crm/validation/eve-stream"; +import { + type EveToolInput, + type EveToolOutcome, + type EveToolOutput, + eveToolInput, + eveToolOutcome, + eveToolOutput, +} from "@crm/validation/eve-tool"; import type { MessageStreamEvent } from "eve/client"; import { defaultMessageReducer, @@ -5,6 +18,7 @@ import { type EveMessageInputRequest, type EveMessagePart, } from "eve/react"; +import { z } from "zod"; export type TranscriptItem = | { kind: "said"; id: string; mine: boolean; text: string } @@ -18,8 +32,8 @@ export type TranscriptItem = kind: "did"; id: string; label: string; - input: Record | null; - output: unknown; + input: EveToolInput; + output: EveToolOutput; tone: Tone; pending: boolean; sources: Source[]; @@ -40,12 +54,9 @@ export type AgentTurnFailure = { kind: "rate-limit" | "restricted" | "credits" | "unknown"; }; -type AgentStreamEvent = { - type: string; - data?: unknown; -}; +type ToolVerbs = Record; -const VERBS: Record = { +const VERBS: ToolVerbs = { read_crm_history: "Read our emails and meetings with them", read_company_history: "Read everything we have on the company", read_deal_history: "Read the deal and where it has been", @@ -140,8 +151,8 @@ export function conversationTimeline< const turnTimes = new Map(); for (const event of events) { - const turnId = stringOf( - recordOf("data" in event ? event.data : undefined).turnId, + const { turnId } = eveTurnReference.parse( + "data" in event ? event.data : undefined, ); if (!turnId || turnTimes.has(turnId)) continue; turnTimes.set(turnId, timestampOf(event.meta.at)); @@ -161,7 +172,7 @@ export function conversationTimeline< id: `assistant:${message.id}`, message, at: - turnTimes.get(stringOf(recordOf(message.metadata).turnId) ?? "") ?? + turnTimes.get(message.metadata?.turnId ?? "") ?? Number.POSITIVE_INFINITY, index, }); @@ -268,18 +279,13 @@ function partId( part: EveMessagePart, index: number, ): string { - const callId = - "toolCallId" in part && typeof part.toolCallId === "string" - ? part.toolCallId - : null; + const callId = "toolCallId" in part ? part.toolCallId : null; return callId ? `${messageId}:${callId}` : `${messageId}:${index}`; } export function toolName(part: EveMessagePart): string { - if (part.type === "dynamic-tool" && "toolName" in part) { - return String(part.toolName); - } + if (part.type === "dynamic-tool") return part.toolName; return part.type.replace(/^tool-/, ""); } @@ -288,15 +294,15 @@ export const TOOL_VERBS = VERBS; export function describe(part: EveMessagePart): string { const tool = toolName(part); const verb = VERBS[tool] ?? humanise(tool); - const reason = output(part)?.reason; + const reason = outcome(part)?.reason ?? null; - return typeof reason === "string" ? `${verb} — ${reason}` : verb; + return reason === null ? verb : `${verb} — ${reason}`; } export function outcomeTone(part: EveMessagePart): Tone { if ("state" in part && part.state === "output-error") return "warning"; - const result = output(part); + const result = outcome(part); if (!result) return "neutral"; if (result.applied === true || result.written === true) return "success"; @@ -306,15 +312,12 @@ export function outcomeTone(part: EveMessagePart): Tone { } export function sourcesOf(part: EveMessagePart): Source[] { - const result = output(part); + const result = outcome(part); if (!result) return []; const urls = new Set(); - for (const key of ["sourceUrl", "profileUrl", "url"]) { - const value = result[key]; - if (typeof value === "string" && /^https?:\/\//.test(value)) { - urls.add(value); - } + for (const link of [result.sourceUrl, result.profileUrl, result.url]) { + if (link !== null) urls.add(link); } return [...urls].map((url) => { @@ -345,7 +348,7 @@ export function pendingQuestion(messages: readonly EveMessage[]) { } export function latestTurnFailure( - events: readonly AgentStreamEvent[], + events: readonly EveStreamEvent[], ): AgentTurnFailure | null { for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]; @@ -357,12 +360,11 @@ export function latestTurnFailure( continue; } - const data = recordOf(event.data); - const message = typeof data.message === "string" ? data.message : ""; - const code = typeof data.code === "string" ? data.code : "AGENT_FAILED"; + const failure = eveTurnFailure.parse(event.data); + const message = failure.message ?? ""; return { - code, + code: failure.code ?? "AGENT_FAILED", kind: /free tier users do not have access|RestrictedModelsError/i.test( message, ) @@ -380,32 +382,25 @@ export function latestTurnFailure( return null; } -function output(part: EveMessagePart): Record | null { - return "output" in part && part.output && typeof part.output === "object" - ? (part.output as Record) - : null; +function payloadOf(part: EveMessagePart) { + return "output" in part ? part.output : undefined; } -function input(part: EveMessagePart): Record | null { - return "input" in part && part.input && typeof part.input === "object" - ? (part.input as Record) - : null; +function output(part: EveMessagePart): EveToolOutput { + return eveToolOutput.parse(payloadOf(part)); } -function errorTextOf(part: EveMessagePart): string | null { - if (!("errorText" in part)) return null; - const text = part.errorText; - return typeof text === "string" && text.trim() ? text : null; +function outcome(part: EveMessagePart): EveToolOutcome { + return eveToolOutcome.parse(payloadOf(part)); } -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; +function input(part: EveMessagePart): EveToolInput { + return eveToolInput.parse("input" in part ? part.input : undefined); } -function stringOf(value: unknown): string | null { - return typeof value === "string" && value ? value : null; +function errorTextOf(part: EveMessagePart): string | null { + const text = "errorText" in part ? part.errorText : undefined; + return text?.trim() ? text : null; } function timestampOf(value: string): number { @@ -459,35 +454,57 @@ export type DealListResult = { hasMore: boolean; }; -export function dealListResultOf(value: unknown): DealListResult | null { - const result = recordOf(value); - const asOf = stringOf(result.asOf); - const criteria = recordOf(result.criteria); - const status = stringOf(criteria.status); - const inactiveForDays = nullableNumberOf(criteria.inactiveForDays); - const companyId = nullableStringOf(criteria.companyId); - const ownerId = nullableStringOf(criteria.ownerId); - const rows = Array.isArray(result.deals) ? result.deals : null; - - if ( - !asOf || - !status || - inactiveForDays === undefined || - companyId === undefined || - ownerId === undefined || - !rows - ) - return null; - - const deals = rows.map(dealListItemOf); - if (deals.some((deal) => deal === null)) return null; +const requiredText = z.string().min(1); + +const finiteNumber = z.number().refine((value) => Number.isFinite(value)); + +const optionalText = z.string().min(1).nullable().catch(null); + +const dealListItem = z.object({ + id: requiredText, + name: requiredText, + stage: requiredText, + amount: finiteNumber.nullable(), + currency: requiredText, + company: z.object({ + id: requiredText, + name: requiredText, + domain: optionalText, + iconUrl: optionalText, + iconDarkUrl: optionalText, + iconTone: optionalText, + logoUrl: optionalText, + }), + owner: z + .object({ + id: requiredText, + name: requiredText, + email: requiredText, + image: optionalText, + }) + .nullable(), + daysSinceLastActivity: finiteNumber, + neverActive: z.boolean().catch(false), + expectedCloseDate: requiredText.nullable(), +}); + +const dealListResult = z + .object({ + asOf: requiredText, + criteria: z.object({ + status: requiredText, + inactiveForDays: finiteNumber.nullable(), + companyId: requiredText.nullable(), + ownerId: requiredText.nullable(), + }), + deals: z.array(dealListItem), + hasMore: z.boolean().catch(false), + }) + .nullable() + .catch(null); - return { - asOf, - criteria: { status, inactiveForDays, companyId, ownerId }, - deals: deals as DealListItem[], - hasMore: result.hasMore === true, - }; +export function dealListResultOf(value: unknown): DealListResult | null { + return dealListResult.parse(value); } export function groupDealListPages( @@ -567,11 +584,13 @@ function stripMarkdownTables(markdown: string): string { .trim(); } -export function splitMarkdownTable(markdown: string): { +export type MarkdownTableSplit = { after: string; before: string; found: boolean; -} { +}; + +export function splitMarkdownTable(markdown: string): MarkdownTableSplit { const lines = markdown.split("\n"); for (let index = 0; index < lines.length - 1; index += 1) { @@ -596,83 +615,6 @@ export function splitMarkdownTable(markdown: string): { return { before: "", after: normaliseMarkdown(markdown), found: false }; } -function dealListItemOf(value: unknown): DealListItem | null { - const deal = recordOf(value); - const company = recordOf(deal.company); - const owner = deal.owner === null ? null : recordOf(deal.owner); - const id = stringOf(deal.id); - const name = stringOf(deal.name); - const stage = stringOf(deal.stage); - const currency = stringOf(deal.currency); - const companyId = stringOf(company.id); - const companyName = stringOf(company.name); - const daysSinceLastActivity = numberOf(deal.daysSinceLastActivity); - const amount = nullableNumberOf(deal.amount); - const expectedCloseDate = nullableStringOf(deal.expectedCloseDate); - - if ( - !id || - !name || - !stage || - !currency || - !companyId || - !companyName || - daysSinceLastActivity === null || - amount === undefined || - expectedCloseDate === undefined - ) { - return null; - } - - const parsedOwner = owner - ? { - id: stringOf(owner.id), - name: stringOf(owner.name), - email: stringOf(owner.email), - image: nullableStringOf(owner.image) ?? null, - } - : null; - if ( - parsedOwner && - (!parsedOwner.id || !parsedOwner.name || !parsedOwner.email) - ) { - return null; - } - - return { - id, - name, - stage, - amount, - currency, - company: { - id: companyId, - name: companyName, - domain: nullableStringOf(company.domain) ?? null, - iconUrl: nullableStringOf(company.iconUrl) ?? null, - iconDarkUrl: nullableStringOf(company.iconDarkUrl) ?? null, - iconTone: nullableStringOf(company.iconTone) ?? null, - logoUrl: nullableStringOf(company.logoUrl) ?? null, - }, - owner: parsedOwner as DealListItem["owner"], - daysSinceLastActivity, - neverActive: deal.neverActive === true, - expectedCloseDate, - }; -} - -function numberOf(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -function nullableNumberOf(value: unknown): number | null | undefined { - return value === null ? null : (numberOf(value) ?? undefined); -} - -function nullableStringOf(value: unknown): string | null | undefined { - return value === null ? null : (stringOf(value) ?? undefined); -} - function isMarkdownTableSeparator(line: string): boolean { return /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)+\s*\|?\s*$/.test(line); } @@ -688,6 +630,11 @@ function normaliseMarkdown(markdown: string): string { export const NEW_THREAD = "new"; +export type ResolvedThread = { + openId: string | null; + current: T | null; +}; + export function resolveThread({ conversations, fromUrl, @@ -696,7 +643,7 @@ export function resolveThread({ conversations: readonly T[]; fromUrl: string | null; landedOn: string | null; -}): { openId: string | null; current: T | null } { +}): ResolvedThread { const openId = fromUrl ?? landedOn; if (!openId || openId === NEW_THREAD) return { openId, current: null }; diff --git a/apps/app/lib/deal-stage.ts b/apps/app/lib/deal-stage.ts index 3da33ccd7..758f08f24 100644 --- a/apps/app/lib/deal-stage.ts +++ b/apps/app/lib/deal-stage.ts @@ -11,7 +11,12 @@ const ORDER = [ DealStage.UNQUALIFIED_TO_BUY, ] as const; -const PRESENTATION: Record = { +type DealStagePresentation = Record< + DealStage, + { label: string; tone: StatusTone } +>; + +const PRESENTATION: DealStagePresentation = { DEMO_BOOKED: { label: "Demo booked", tone: "neutral" }, QUALIFIED_TO_BUY: { label: "Qualified to buy", tone: "info" }, DECISION_MAKER_BOUGHT_IN: { label: "Decision maker in", tone: "info" }, diff --git a/apps/app/lib/enrichment-status.ts b/apps/app/lib/enrichment-status.ts index 4264ee42a..7024158d0 100644 --- a/apps/app/lib/enrichment-status.ts +++ b/apps/app/lib/enrichment-status.ts @@ -1,10 +1,12 @@ import type { EnrichmentStatus } from "@crm/db/enums"; import type { StatusTone } from "@crm/ui/components/status-indicator"; -const PRESENTATION: Record< +type EnrichmentPresentation = Record< EnrichmentStatus, { label: string; tone: StatusTone; busy?: boolean } -> = { +>; + +const PRESENTATION: EnrichmentPresentation = { PENDING: { label: "Not researched", tone: "neutral" }, RUNNING: { label: "Researching", tone: "info", busy: true }, COMPLETE: { label: "Enriched", tone: "success" }, diff --git a/apps/app/lib/onboarding.ts b/apps/app/lib/onboarding.ts index fd4c184c6..ea8273789 100644 --- a/apps/app/lib/onboarding.ts +++ b/apps/app/lib/onboarding.ts @@ -1,4 +1,5 @@ import type { NextRequest } from "next/server"; +import { z } from "zod"; import { API_URL } from "@/lib/env"; export const ONBOARDING_PATH = "/onboarding"; @@ -9,10 +10,23 @@ const GATE_TIMEOUT_MS = 2_000; export type Gate = "settled" | "required" | "unknown"; -async function read( - request: NextRequest, - procedure: string, -): Promise { +const procedureResult = z + .object({ result: z.object({ data: z.json() }).catch({ data: null }) }) + .catch({ result: { data: null } }); + +const workspaceAnswer = z + .object({ + onboarded: z.boolean().nullable().catch(null), + canRename: z.boolean().nullable().catch(null), + slug: z.string().min(1).nullable().catch(null), + }) + .catch({ onboarded: null, canRename: null, slug: null }); + +const researchKeyAnswer = z + .object({ configured: z.boolean().nullable().catch(null) }) + .catch({ configured: null }); + +async function read(request: NextRequest, procedure: string) { const cookie = request.headers.get("cookie"); if (!cookie) return null; @@ -26,9 +40,7 @@ async function read( if (!response.ok) return null; - const body = (await response.json()) as { result?: { data?: T } }; - - return body.result?.data ?? null; + return procedureResult.parse(await response.json()).result.data; } catch { return null; } @@ -39,31 +51,29 @@ export type WorkspaceGate = { gate: Gate; slug: string | null }; export async function readWorkspaceGate( request: NextRequest, ): Promise { - const workspace = await read<{ - onboarded?: boolean; - canRename?: boolean; - slug?: string; - }>(request, "workspace.get"); + const workspace = workspaceAnswer.parse(await read(request, "workspace.get")); - const slug = workspace?.slug ? workspace.slug : null; + const slug = workspace.slug; - if (typeof workspace?.onboarded !== "boolean") { + if (workspace.onboarded === null) { return { gate: "unknown", slug }; } return { - gate: workspace.onboarded || !workspace.canRename ? "settled" : "required", + gate: + workspace.onboarded || workspace.canRename !== true + ? "settled" + : "required", slug, }; } export async function readResearchGate(request: NextRequest): Promise { - const key = await read<{ configured?: boolean }>( - request, - "settings.researchKey", + const { configured } = researchKeyAnswer.parse( + await read(request, "settings.researchKey"), ); - if (typeof key?.configured !== "boolean") return "unknown"; + if (configured === null) return "unknown"; - return key.configured ? "settled" : "required"; + return configured ? "settled" : "required"; } diff --git a/apps/app/lib/social-links.ts b/apps/app/lib/social-links.ts index cfe73aff5..7315b5f74 100644 --- a/apps/app/lib/social-links.ts +++ b/apps/app/lib/social-links.ts @@ -35,10 +35,13 @@ const CONTACT_LINKS: SocialLink[] = [ { key: "githubUrl", label: "GitHub", icon: LogoGithub }, ]; -function present(record: T, links: SocialLink[]) { +function present>( + record: T, + links: SocialLink[], +) { return links.flatMap((link) => { const href = record[link.key]; - return typeof href === "string" && href ? [{ ...link, href }] : []; + return href ? [{ ...link, href }] : []; }); } diff --git a/apps/app/lib/trpc/query-client.ts b/apps/app/lib/trpc/query-client.ts index e38f3e899..49bcbd977 100644 --- a/apps/app/lib/trpc/query-client.ts +++ b/apps/app/lib/trpc/query-client.ts @@ -1,4 +1,13 @@ import { QueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +const queryFailure = z + .object({ + data: z + .object({ httpStatus: z.number().nullable().catch(null) }) + .catch({ httpStatus: null }), + }) + .catch({ data: { httpStatus: null } }); export function makeQueryClient(): QueryClient { return new QueryClient({ @@ -8,15 +17,10 @@ export function makeQueryClient(): QueryClient { }); } -function retryQuery(failureCount: number, error: unknown): boolean { - const data = - error && typeof error === "object" && "data" in error ? error.data : null; - const status = - data && typeof data === "object" && "httpStatus" in data - ? data.httpStatus - : null; +function retryQuery(failureCount: number, error: Error): boolean { + const { httpStatus } = queryFailure.parse(error).data; - if (typeof status === "number" && status >= 400 && status < 500) { + if (httpStatus !== null && httpStatus >= 400 && httpStatus < 500) { return false; } diff --git a/apps/app/package.json b/apps/app/package.json index 76ccf5d39..48519e3c8 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -34,7 +34,8 @@ "posthog-js": "^1.413.2", "react": "19.2.4", "react-dom": "19.2.4", - "sonner": "^2.0.7" + "sonner": "^2.0.7", + "zod": "^4.4.3" }, "devDependencies": { "@crm/typescript-config": "workspace:*", diff --git a/apps/app/test/agent-results.spec.ts b/apps/app/test/agent-results.spec.ts index 0e781baf0..f66c5ef45 100644 --- a/apps/app/test/agent-results.spec.ts +++ b/apps/app/test/agent-results.spec.ts @@ -30,7 +30,7 @@ const page = (status: string, ids: string[]) => ({ const did = ( id: string, - output: unknown, + output: Extract["output"], extra: Partial> = {}, ): TranscriptItem => ({ kind: "did", diff --git a/bun.lock b/bun.lock index c63e49bc2..f3d583b64 100644 --- a/bun.lock +++ b/bun.lock @@ -101,6 +101,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "sonner": "^2.0.7", + "zod": "^4.4.3", }, "devDependencies": { "@crm/typescript-config": "workspace:*", diff --git a/packages/validation/package.json b/packages/validation/package.json index 5f0bd6b84..a791a03ea 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -6,7 +6,10 @@ "exports": { ".": "./src/index.ts", "./agent-events": "./src/agent-events.ts", - "./agent-manifest": "./src/agent-manifest.ts" + "./agent-manifest": "./src/agent-manifest.ts", + "./builder-question": "./src/builder-question.ts", + "./eve-stream": "./src/eve-stream.ts", + "./eve-tool": "./src/eve-tool.ts" }, "scripts": { "check-types": "tsc --noEmit", diff --git a/packages/validation/src/builder-question.ts b/packages/validation/src/builder-question.ts new file mode 100644 index 000000000..d412bcd8a --- /dev/null +++ b/packages/validation/src/builder-question.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +const optionalText = z.string().optional().catch(undefined); + +const builderQuestionOption = z.object({ + id: z.string().min(1), + label: z.string().min(1), + description: optionalText, + style: z.enum(["danger", "default", "primary"]).optional().catch(undefined), +}); + +export type BuilderQuestionOption = z.infer; + +const askedQuestion = z + .object({ + kind: z.literal("question"), + requestId: z.string().min(1), + prompt: z.string().min(1), + display: z + .enum(["confirmation", "select", "text"]) + .optional() + .catch(undefined), + options: z + .array(builderQuestionOption.nullable().catch(null)) + .catch([]) + .transform((entries) => entries.filter((entry) => entry !== null)), + allowFreeform: z.boolean().optional().catch(undefined), + }) + .transform((question) => ({ + kind: question.kind, + requestId: question.requestId, + prompt: question.prompt, + display: question.display, + options: question.options, + allowFreeform: + question.allowFreeform === true || + question.display === "text" || + question.options.length === 0, + })); + +export type BuilderQuestion = z.infer; + +export const builderQuestion = askedQuestion.nullable().catch(null); diff --git a/packages/validation/src/eve-stream.ts b/packages/validation/src/eve-stream.ts new file mode 100644 index 000000000..00f182a3e --- /dev/null +++ b/packages/validation/src/eve-stream.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +export type EveStreamEvent = { + type: string; + data?: unknown; +}; + +const text = z.string().nullable().catch(null); + +const identifier = z.string().min(1).nullable().catch(null); + +export const eveTurnReference = z + .object({ turnId: identifier }) + .catch({ turnId: null }); + +export type EveTurnReference = z.infer; + +export const eveTurnFailure = z + .object({ code: text, message: text }) + .catch({ code: null, message: null }); + +export type EveTurnFailure = z.infer; + +const eveRequestedAction = z + .object({ + kind: text, + name: text, + subagentName: text, + callId: text, + }) + .catch({ kind: null, name: null, subagentName: null, callId: null }); + +export const eveRequestedActions = z + .object({ actions: z.array(eveRequestedAction).catch([]) }) + .catch({ actions: [] }); + +export type EveRequestedActions = z.infer; + +const eveCallReference = z.object({ callId: text }).catch({ callId: null }); + +export const eveSettledAction = z + .object({ + callId: text, + result: eveCallReference, + action: eveCallReference, + }) + .catch({ callId: null, result: { callId: null }, action: { callId: null } }); + +export type EveSettledAction = z.infer; diff --git a/packages/validation/src/eve-tool.ts b/packages/validation/src/eve-tool.ts new file mode 100644 index 000000000..76c4eb805 --- /dev/null +++ b/packages/validation/src/eve-tool.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +const json = z.json(); + +const eveToolFields = z.record(z.string(), json); + +export type EveToolFields = z.infer; + +export const eveToolInput = eveToolFields.nullable().catch(null); + +export type EveToolInput = z.infer; + +export const eveToolOutput = eveToolFields.nullable().catch(null); + +export type EveToolOutput = z.infer; + +export const eveToolText = z.string().catch(""); + +const flag = z.boolean().nullable().catch(null); + +const link = z + .string() + .regex(/^https?:\/\//) + .nullable() + .catch(null); + +export const eveToolOutcome = z + .object({ + reason: z.string().nullable().catch(null), + applied: flag, + written: flag, + stored: flag, + sourceUrl: link, + profileUrl: link, + url: link, + }) + .nullable() + .catch(null); + +export type EveToolOutcome = z.infer; diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 0f2481273..fb3c1f763 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -2,9 +2,20 @@ import type { ZodType, z } from "zod"; import * as agentEvents from "./agent-events"; import * as agentManifest from "./agent-manifest"; import * as agents from "./agents"; +import * as builderQuestion from "./builder-question"; +import * as eveStream from "./eve-stream"; +import * as eveTool from "./eve-tool"; import * as slack from "./slack"; -export const schemas = { agentEvents, agentManifest, agents, slack } as const; +export const schemas = { + agentEvents, + agentManifest, + agents, + builderQuestion, + eveStream, + eveTool, + slack, +} as const; export type { CrmEventTask } from "./agent-events"; export type { @@ -25,6 +36,23 @@ export type { InputRequested, Permission, } from "./agents"; +export type { + BuilderQuestion, + BuilderQuestionOption, +} from "./builder-question"; +export type { + EveRequestedActions, + EveSettledAction, + EveStreamEvent, + EveTurnFailure, + EveTurnReference, +} from "./eve-stream"; +export type { + EveToolFields, + EveToolInput, + EveToolOutcome, + EveToolOutput, +} from "./eve-tool"; export type { AuthTest, Installation, JoinPayload, Reply } from "./slack"; export class InvalidInput extends Error { From 288b1b7a0917d2917229ff3956c12d5478cc665f Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:01:54 -0700 Subject: [PATCH 4/7] CMP-82 refactor: parse provider, feed and Json-column boundaries in the API The exchange-rate feed, the model catalogue, the SSO oidcConfig string column, stored recipients and the browser tracking payload are each parsed where they arrive. Per-row safeParse keeps the old degradation: one bad model or recipient is dropped, not the whole response, and the rates fetcher still returns null and warns rather than throwing, which docs/currency.md depends on. Activity.meta gets a schema in packages/validation because the API, the agent and the app all read that column. The SORTABLE dictionaries in five services become one named contract. translate now throws and returns never instead of returning unknown - every call site already threw its result, so the thrown value, message and status are unchanged. Catch bindings keep unknown and are renamed to cause. TypeScript types them that way by language rule and narrowing one needs a cast. --- apps/api/src/activities/activities.service.ts | 6 +- .../src/agent/agent-definitions.service.ts | 47 +++++----- apps/api/src/agent/agent-queue.service.ts | 12 +-- apps/api/src/agent/agent-trigger.service.ts | 6 +- apps/api/src/agent/research-key.service.ts | 22 ++--- apps/api/src/backfill/backfill.service.ts | 12 ++- apps/api/src/backfill/image-mirror.service.ts | 53 ++++++++---- apps/api/src/companies/companies.service.ts | 20 ++--- apps/api/src/config/env.validation.ts | 6 +- apps/api/src/contacts/contacts.service.ts | 32 +++---- apps/api/src/crm/enrichment-log.service.ts | 3 +- apps/api/src/currency/rates.service.ts | 42 +++++---- apps/api/src/dashboard/dashboard.service.ts | 3 +- apps/api/src/deals/deals.service.ts | 26 +++--- apps/api/src/google/conversation.service.ts | 34 ++++---- apps/api/src/mailbox/mailbox.constants.ts | 8 +- apps/api/src/mailbox/participants.ts | 10 ++- apps/api/src/mailbox/sync-state.service.ts | 15 ++-- apps/api/src/mailbox/thread-writer.service.ts | 26 +++--- apps/api/src/main.ts | 4 +- .../api/src/settings/model-catalog.service.ts | 86 ++++++++++--------- apps/api/src/settings/settings.service.ts | 4 +- .../api/src/slack/slack-connection.service.ts | 15 ++-- apps/api/src/sso/sso.service.ts | 54 ++++++------ apps/api/src/telemetry/rollup.service.ts | 15 ++-- .../src/tracking/tracking-filing.service.ts | 10 ++- .../src/tracking/tracking-ingest.service.ts | 45 ++++++---- apps/api/src/tracking/tracking.controller.ts | 27 ++++-- apps/api/src/trpc/error-formatter.ts | 42 +++++---- apps/api/src/trpc/list-input.ts | 22 +++-- apps/api/src/workspace/workspace.service.ts | 6 +- apps/api/test/agent-runs.spec.ts | 5 +- apps/api/test/conversation-sharing.spec.ts | 19 ++-- apps/api/test/conversations.spec.ts | 43 +++++----- apps/api/test/error-formatter.spec.ts | 2 +- apps/api/test/logging.spec.ts | 10 ++- apps/api/test/mailbox-api-client.spec.ts | 3 +- apps/api/test/outlook-sync.spec.ts | 33 ++++--- apps/api/test/slack-channels.spec.ts | 5 +- apps/api/test/sso.spec.ts | 4 +- packages/validation/package.json | 1 + packages/validation/src/activity-meta.ts | 9 ++ packages/validation/src/index.ts | 3 + 43 files changed, 490 insertions(+), 360 deletions(-) create mode 100644 packages/validation/src/activity-meta.ts diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 23510a356..5fc2353bc 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -1,4 +1,5 @@ import { ActivityType, type Db, type Prisma } from "@crm/db"; +import { activityMeta } from "@crm/validation/activity-meta"; import { BadRequestException, Injectable, @@ -80,7 +81,8 @@ export class ActivitiesService { const rows = await this.db.activity.findMany({ where, take: input.limit + 1, - ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + cursor: input.cursor ? { id: input.cursor } : undefined, + skip: input.cursor ? 1 : undefined, orderBy: [ { occurredAt: { sort: "desc", nulls: "last" } }, { id: "desc" }, @@ -273,7 +275,7 @@ function serializeEntry(entry: Entry) { dueAt: entry.dueAt?.toISOString() ?? null, completedAt: entry.completedAt?.toISOString() ?? null, createdAt: entry.createdAt.toISOString(), - meta: entry.meta as Record | null, + meta: activityMeta.parse(entry.meta), emailThread: entry.emailThread ? { diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 9c9f0fc97..a211d5718 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -7,6 +7,7 @@ import { Injectable, NotFoundException, } from "@nestjs/common"; +import { z } from "zod"; import { InjectDatabase } from "../database/database.constants"; import { AgentAccessService } from "./agent-access.service"; import { AgentTriggerService } from "./agent-trigger.service"; @@ -21,6 +22,18 @@ import { const INSTRUCTIONS_PATH = "agent/instructions.md"; +type VersionMetadata = { + name?: string; + description?: string | null; +}; + +const capabilityName = z.string().nullable().catch(null); + +const versionValidation = z + .object({ capabilities: z.array(z.json()).catch([]) }) + .catchall(z.json()) + .catch({ capabilities: [] }); + @Injectable() export class AgentDefinitionsService { constructor( @@ -798,10 +811,7 @@ export class AgentDefinitionsService { } } -function versionMetadata(manifest: Prisma.JsonValue): { - name?: string; - description?: string | null; -} { +function versionMetadata(manifest: Prisma.JsonValue): VersionMetadata { const summary = readAgentManifestSummary(manifest); const name = summary.name?.trim() ?? ""; const description = @@ -809,13 +819,14 @@ function versionMetadata(manifest: Prisma.JsonValue): { ? undefined : summary.description.trim() || null; - return { - ...(name ? { name } : {}), - ...(description !== undefined ? { description } : {}), - }; + const metadata: VersionMetadata = {}; + if (name) metadata.name = name; + if (description !== undefined) metadata.description = description; + + return metadata; } -function readCapabilities(manifest: unknown) { +function readCapabilities(manifest: Prisma.JsonValue | undefined) { const parsed = schemas.agents.capabilities.safeParse(manifest); if (!parsed.success) { @@ -858,28 +869,24 @@ function reviseSummary(input: { } function reviseValidation( - validation: unknown, + validation: Prisma.JsonValue, before: string[], after: string[], ): Prisma.InputJsonValue { const removed = before.filter((type) => !after.includes(type)); - const base = - validation && typeof validation === "object" && !Array.isArray(validation) - ? (validation as Record) - : {}; + const base = versionValidation.parse(validation); - const capabilities = Array.isArray(base.capabilities) - ? base.capabilities.filter( - (entry) => typeof entry === "string" && !removed.includes(entry), - ) - : []; + const capabilities = base.capabilities.flatMap((entry) => { + const name = capabilityName.parse(entry); + return name !== null && !removed.includes(name) ? [name] : []; + }); return { ...base, status: "passed", checkedAt: new Date().toISOString(), capabilities, - } as Prisma.InputJsonValue; + }; } async function nextVersionNumber( diff --git a/apps/api/src/agent/agent-queue.service.ts b/apps/api/src/agent/agent-queue.service.ts index 2089400dd..e1bfa2c8b 100644 --- a/apps/api/src/agent/agent-queue.service.ts +++ b/apps/api/src/agent/agent-queue.service.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import type { Db, Prisma } from "@crm/db"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -18,12 +18,12 @@ export class AgentQueueService { companyId?: string; contactId?: string; }): Promise { + const where: Prisma.AgentTaskWhereInput = { finishedAt: null }; + if (subject.companyId) where.companyId = subject.companyId; + if (subject.contactId) where.contactId = subject.contactId; + const row = await this.db.agentTask.findFirst({ - where: { - finishedAt: null, - ...(subject.companyId ? { companyId: subject.companyId } : {}), - ...(subject.contactId ? { contactId: subject.contactId } : {}), - }, + where, select: { id: true }, }); diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index 1d5928dda..4b8005138 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -316,11 +316,13 @@ export class AgentTriggerService { finishedAt: null, [subject]: { in: ids }, }, - select: { [subject]: true }, + select: { companyId: true, contactId: true }, }); const taken = new Set( - outstanding.map((row) => (row as Record)[subject]), + outstanding.map((row) => + subject === "contactId" ? row.contactId : row.companyId, + ), ); const fresh = ids.filter((id) => !taken.has(id)); diff --git a/apps/api/src/agent/research-key.service.ts b/apps/api/src/agent/research-key.service.ts index fd9e3edb1..83808cf81 100644 --- a/apps/api/src/agent/research-key.service.ts +++ b/apps/api/src/agent/research-key.service.ts @@ -1,8 +1,16 @@ import { Injectable, Logger } from "@nestjs/common"; +import { z } from "zod"; import { bridge } from "./bridge"; const VERIFY_TIMEOUT_MS = 20_000; +const verifyAnswer = z + .object({ + outcome: z.string().nullable().catch(null), + reason: z.string().nullable().catch(null), + }) + .catch({ outcome: null, reason: null }); + export type KeyCheck = | { outcome: "valid" } | { outcome: "invalid"; reason: string } @@ -44,26 +52,18 @@ export class ResearchKeyService { return this.cannotTell(`The agent answered ${response.status}.`); } - const body = (await response.json()) as { - outcome?: string; - reason?: string; - }; + const body = verifyAnswer.parse(await response.json()); if (body.outcome === "valid") return { outcome: "valid" }; if (body.outcome === "invalid") { return { outcome: "invalid", - reason: - typeof body.reason === "string" && body.reason - ? body.reason - : "Context did not recognise that API key.", + reason: body.reason || "Context did not recognise that API key.", }; } - return this.cannotTell( - typeof body.reason === "string" ? body.reason : "No answer.", - ); + return this.cannotTell(body.reason ?? "No answer."); } catch (error) { return this.cannotTell( error instanceof Error ? error.message : String(error), diff --git a/apps/api/src/backfill/backfill.service.ts b/apps/api/src/backfill/backfill.service.ts index 8f4b96071..303c82375 100644 --- a/apps/api/src/backfill/backfill.service.ts +++ b/apps/api/src/backfill/backfill.service.ts @@ -273,12 +273,14 @@ export class BackfillService implements OnModuleInit { .map((row) => row.companyId) .filter((id): id is string => id !== null); - return { + const where: Prisma.CompanyWhereInput = { domain: { not: null }, logoUrl: null, iconUrl: null, - ...(recentlyChecked.length > 0 ? { id: { notIn: recentlyChecked } } : {}), }; + if (recentlyChecked.length > 0) where.id = { notIn: recentlyChecked }; + + return where; } /** @@ -311,15 +313,17 @@ export class BackfillService implements OnModuleInit { .map((row) => row.contactId) .filter((id): id is string => id !== null); - return { + const where: Prisma.ContactWhereInput = { imageUrl: null, - ...(recentlyChecked.length > 0 ? { id: { notIn: recentlyChecked } } : {}), OR: [ { linkedinUrl: { not: null } }, { githubUrl: { not: null } }, { company: { domain: { not: null } } }, ], }; + if (recentlyChecked.length > 0) where.id = { notIn: recentlyChecked }; + + return where; } private contactsNeverResearched(): Prisma.ContactWhereInput { diff --git a/apps/api/src/backfill/image-mirror.service.ts b/apps/api/src/backfill/image-mirror.service.ts index c6f46c850..1f295de76 100644 --- a/apps/api/src/backfill/image-mirror.service.ts +++ b/apps/api/src/backfill/image-mirror.service.ts @@ -1,11 +1,27 @@ import type { Db, Prisma } from "@crm/db"; import { blobEnabled, mirror } from "@crm/db/blob"; -import { BLOB_HOST_SUFFIX, COMPANY_IMAGE_FIELDS } from "@crm/db/images"; +import { + BLOB_HOST_SUFFIX, + COMPANY_IMAGE_FIELDS, + type CompanyImageField, +} from "@crm/db/images"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; const MAX_PER_SWEEP = 25; +type CompanyImageRow = Record; + +const EXTERNAL_CONTACT_IMAGE: Prisma.ContactWhereInput = { + imageUrl: { not: null }, + NOT: { imageUrl: { contains: BLOB_HOST_SUFFIX } }, +}; + +const EXTERNAL_USER_IMAGE: Prisma.UserWhereInput = { + image: { not: null }, + NOT: { image: { contains: BLOB_HOST_SUFFIX } }, +}; + export type ImageMirrorResult = { scanned: number; copied: number; @@ -44,7 +60,7 @@ export class ImageMirrorService { private async sweepCompanies(): Promise { const rows = await this.db.company.findMany({ where: { - OR: COMPANY_IMAGE_FIELDS.map((field) => external(field)), + OR: COMPANY_IMAGE_FIELDS.map(externalCompanyImage), }, orderBy: { createdAt: "asc" }, take: MAX_PER_SWEEP, @@ -86,7 +102,7 @@ export class ImageMirrorService { private async sweepContacts(): Promise { const rows = await this.db.contact.findMany({ - where: external("imageUrl"), + where: EXTERNAL_CONTACT_IMAGE, orderBy: { createdAt: "asc" }, take: MAX_PER_SWEEP, select: { id: true, imageUrl: true }, @@ -113,7 +129,7 @@ export class ImageMirrorService { private async sweepUsers(): Promise { const rows = await this.db.user.findMany({ - where: external("image"), + where: EXTERNAL_USER_IMAGE, take: MAX_PER_SWEEP, select: { id: true, image: true }, }); @@ -138,20 +154,21 @@ export class ImageMirrorService { } } -function external( - field: T, -): Record & { NOT: Record } { - return { - ...({ [field]: { not: null } } as Record), - NOT: { [field]: { contains: BLOB_HOST_SUFFIX } } as Record< - T, - { contains: string } - >, - }; +function externalCompanyImage( + field: CompanyImageField, +): Prisma.CompanyWhereInput { + const mirrored: Prisma.CompanyWhereInput = {}; + mirrored[field] = { contains: BLOB_HOST_SUFFIX }; + + const where: Prisma.CompanyWhereInput = { NOT: mirrored }; + where[field] = { not: null }; + + return where; } -function unchanged(row: Record): Prisma.CompanyWhereInput { - return Object.fromEntries( - COMPANY_IMAGE_FIELDS.map((field) => [field, row[field] ?? null]), - ); +function unchanged(row: CompanyImageRow): Prisma.CompanyWhereInput { + const where: Prisma.CompanyWhereInput = {}; + for (const field of COMPANY_IMAGE_FIELDS) where[field] = row[field] ?? null; + + return where; } diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index 80e79dc7e..cc70757f8 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -29,6 +29,7 @@ import { FACET_ALL, FACET_UNASSIGNED, type ListResult, + type OrderByColumns, ownerFilter, paginate, resolveOrderBy, @@ -75,10 +76,7 @@ export type CompanyRow = { fields: Record; }; -const SORTABLE: Record< - string, - (dir: Prisma.SortOrder) => Prisma.CompanyOrderByWithRelationInput -> = { +const SORTABLE: OrderByColumns = { name: (dir) => ({ name: dir }), domain: (dir) => ({ domain: dir }), industry: (dir) => ({ industry: dir }), @@ -606,17 +604,17 @@ export class CompaniesService { }; } - private translate(error: unknown, id: string): unknown { - if (error instanceof PrismaNamespace.PrismaClientKnownRequestError) { - if (error.code === "P2025") { - return new NotFoundException(`No company with id ${id}.`); + private translate(cause: unknown, id: string): never { + if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) { + if (cause.code === "P2025") { + throw new NotFoundException(`No company with id ${id}.`); } - if (error.code === "P2002") { - return new ConflictException( + if (cause.code === "P2002") { + throw new ConflictException( "Another company already uses that domain.", ); } } - return error; + throw cause; } } diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 2f81dc723..08cb676c2 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -128,9 +128,9 @@ export class EnvironmentVariables { CRM_TELEMETRY_DISABLED?: string; } -export function validateEnv( - config: Record, -): EnvironmentVariables { +export type RawEnvironment = Record; + +export function validateEnv(config: RawEnvironment): EnvironmentVariables { const validated = plainToInstance(EnvironmentVariables, config, { enableImplicitConversion: true, exposeDefaultValues: true, diff --git a/apps/api/src/contacts/contacts.service.ts b/apps/api/src/contacts/contacts.service.ts index 6518d060f..fabdbd56a 100644 --- a/apps/api/src/contacts/contacts.service.ts +++ b/apps/api/src/contacts/contacts.service.ts @@ -29,6 +29,7 @@ import { FACET_ALL, FACET_UNASSIGNED, type ListResult, + type OrderByColumns, ownerFilter, paginate, resolveOrderBy, @@ -61,7 +62,9 @@ const COMPANY_SELECT = { const NO_COMPANY = "none"; -const FACT_COLUMNS: Record = { +type FactColumns = Record; + +const FACT_COLUMNS: FactColumns = { title: "title", linkedinUrl: "linkedinUrl", twitterUrl: "twitterUrl", @@ -95,10 +98,7 @@ export type ContactRow = { fields: Record; }; -const SORTABLE: Record< - string, - (dir: Prisma.SortOrder) => Prisma.ContactOrderByWithRelationInput[] -> = { +const SORTABLE: OrderByColumns = { name: (dir) => [{ lastName: dir }, { firstName: dir }], email: (dir) => [{ email: dir }], title: (dir) => [{ title: dir }, { lastName: "asc" }], @@ -403,7 +403,9 @@ export class ContactsService { if (input.firstName !== undefined) data.firstName = input.firstName.trim(); if (input.lastName !== undefined) data.lastName = blankToNull(input.lastName); - if (input.email !== undefined) data.email = normalizeEmail(input.email); + const email = + input.email === undefined ? null : normalizeEmail(input.email); + if (input.email !== undefined) data.email = email; if (input.phone !== undefined) data.phone = blankToNull(input.phone); if (input.title !== undefined) data.title = blankToNull(input.title); if (input.linkedinUrl !== undefined) { @@ -438,8 +440,8 @@ export class ContactsService { select: { id: true, firstName: true, lastName: true }, }); - if (typeof data.email === "string") { - await this.allowAgain(tx, data.email); + if (email !== null) { + await this.allowAgain(tx, email); } return updated; @@ -753,17 +755,17 @@ export class ContactsService { }; } - private translate(error: unknown, id: string): unknown { - if (error instanceof PrismaNamespace.PrismaClientKnownRequestError) { - if (error.code === "P2025") { - return new NotFoundException(`No contact with id ${id}.`); + private translate(cause: unknown, id: string): never { + if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) { + if (cause.code === "P2025") { + throw new NotFoundException(`No contact with id ${id}.`); } - if (error.code === "P2002") { - return new ConflictException( + if (cause.code === "P2002") { + throw new ConflictException( "Another contact already uses that email address.", ); } } - return error; + throw cause; } } diff --git a/apps/api/src/crm/enrichment-log.service.ts b/apps/api/src/crm/enrichment-log.service.ts index 73f335693..c5087d8e8 100644 --- a/apps/api/src/crm/enrichment-log.service.ts +++ b/apps/api/src/crm/enrichment-log.service.ts @@ -1,4 +1,5 @@ import { ActivityType, type Db } from "@crm/db"; +import type { ActivityMetaFields } from "@crm/validation/activity-meta"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { ActivityStampService } from "./activity-stamp.service"; @@ -8,7 +9,7 @@ export type EnrichmentEvent = { contactId?: string | null; subject: string; body?: string | null; - meta?: Record; + meta?: ActivityMetaFields; }; @Injectable() diff --git a/apps/api/src/currency/rates.service.ts b/apps/api/src/currency/rates.service.ts index eefab65a7..a37c31f9e 100644 --- a/apps/api/src/currency/rates.service.ts +++ b/apps/api/src/currency/rates.service.ts @@ -11,6 +11,7 @@ import { writeRatesRefreshedAt, } from "@crm/db/settings"; import { Injectable, Logger } from "@nestjs/common"; +import { z } from "zod"; import { InjectDatabase } from "../database/database.constants"; export const RATES_PROVIDER = "open.er-api.com"; @@ -31,17 +32,29 @@ export interface RateRefresh { reason: string | null; } -interface OpenExchangeResponse { - result?: unknown; - base_code?: unknown; - time_last_update_unix?: unknown; - rates?: Record; - "error-type"?: unknown; -} - -function parseAsOf(value: unknown): Date | null { - if (typeof value !== "number" || !Number.isFinite(value)) return null; - const date = new Date(value * 1000); +const UNREADABLE_FEED = { + result: "", + time_last_update_unix: null, + rates: {}, + "error-type": null, +}; + +const openExchangeResponse = z + .object({ + result: z.string().catch(""), + time_last_update_unix: z + .number() + .refine(Number.isFinite) + .nullable() + .catch(null), + rates: z.record(z.string(), z.json()).catch({}), + "error-type": z.string().nullable().catch(null), + }) + .catch(UNREADABLE_FEED); + +function parseAsOf(seconds: number | null): Date | null { + if (seconds === null) return null; + const date = new Date(seconds * 1000); return Number.isNaN(date.getTime()) ? null : date; } @@ -179,15 +192,14 @@ export class RatesService { return null; } - const body = (await response.json()) as OpenExchangeResponse; + const body = openExchangeResponse.parse(await response.json()); if (body.result !== "success") { this.logger.warn({ message: "Exchange rate provider refused the request", base, attempt, - errorType: - typeof body["error-type"] === "string" ? body["error-type"] : null, + errorType: body["error-type"], }); return null; } @@ -195,7 +207,7 @@ export class RatesService { const asOf = parseAsOf(body.time_last_update_unix) ?? new Date(); const rates = new Map(); - for (const [code, value] of Object.entries(body.rates ?? {})) { + for (const [code, value] of Object.entries(body.rates)) { const quoteCurrency = normalizeCurrency(code); if (!isCurrencyCode(quoteCurrency)) continue; if (quoteCurrency === normalizeCurrency(base)) continue; diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index f338fd466..75065fc24 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -1,5 +1,6 @@ import { ActivityType, type Db, DealStage } from "@crm/db"; import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; +import { activityMeta } from "@crm/validation/activity-meta"; import { Injectable } from "@nestjs/common"; import { toCents } from "../crm/values"; import { ConversionService } from "../currency/conversion.service"; @@ -282,7 +283,7 @@ export class DashboardService { recentActivity: recentActivity.map(({ createdAt, meta, ...entry }) => ({ ...entry, createdAt: createdAt.toISOString(), - meta: meta as Record | null, + meta: activityMeta.parse(meta), })), }; } diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 5bb09fecf..3f5b23d03 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -38,6 +38,7 @@ import { FACET_ALL, FACET_UNASSIGNED, type ListResult, + type OrderByColumns, paginate, resolveOrderBy, } from "../trpc/list-input"; @@ -83,10 +84,7 @@ const CONTACT_SELECT = { const LOSING = new Set(LOSING_DEAL_STAGES); -const SORTABLE: Record< - string, - (dir: Prisma.SortOrder) => Prisma.DealOrderByWithRelationInput[] -> = { +const SORTABLE: OrderByColumns = { name: (dir) => [{ name: dir }], company: (dir) => [{ company: { name: dir } }, { name: "asc" }], stage: (dir) => [{ stage: dir }, { expectedCloseDate: "asc" }], @@ -709,26 +707,26 @@ export class DealsService { }; } - private translate(error: unknown, id: string): unknown { + private translate(cause: unknown, id: string): never { if ( - error instanceof PrismaNamespace.PrismaClientKnownRequestError && - error.code === "P2025" + cause instanceof PrismaNamespace.PrismaClientKnownRequestError && + cause.code === "P2025" ) { - return new NotFoundException(`No deal with id ${id}.`); + throw new NotFoundException(`No deal with id ${id}.`); } - return this.translateRelations(error); + return this.translateRelations(cause); } - private translateRelations(error: unknown): unknown { + private translateRelations(cause: unknown): never { if ( - error instanceof PrismaNamespace.PrismaClientKnownRequestError && - (error.code === "P2003" || error.code === "P2025") + cause instanceof PrismaNamespace.PrismaClientKnownRequestError && + (cause.code === "P2003" || cause.code === "P2025") ) { - return new BadRequestException( + throw new BadRequestException( "That company or owner does not exist any more.", ); } - return error; + throw cause; } } diff --git a/apps/api/src/google/conversation.service.ts b/apps/api/src/google/conversation.service.ts index b995c74d0..232b84f58 100644 --- a/apps/api/src/google/conversation.service.ts +++ b/apps/api/src/google/conversation.service.ts @@ -1,7 +1,18 @@ -import type { Db } from "@crm/db"; +import type { Db, Prisma } from "@crm/db"; import { Injectable, NotFoundException } from "@nestjs/common"; +import { z } from "zod"; import { InjectDatabase } from "../database/database.constants"; +const storedRecipient = z.object({ + email: z.string(), + name: z.string().nullable().catch(null), + kind: z.string().catch("to"), +}); + +type StoredRecipient = z.infer; + +const storedRecipients = z.array(z.json()).catch([]); + @Injectable() export class ConversationService { constructor(@InjectDatabase() private readonly db: Db) {} @@ -142,22 +153,9 @@ export class ConversationService { } } -function recipientsOf( - value: unknown, -): { email: string; name: string | null; kind: string }[] { - if (!Array.isArray(value)) return []; - - return value.flatMap((entry) => { - if (typeof entry !== "object" || entry === null) return []; - const record = entry as Record; - if (typeof record.email !== "string") return []; - - return [ - { - email: record.email, - name: typeof record.name === "string" ? record.name : null, - kind: typeof record.kind === "string" ? record.kind : "to", - }, - ]; +function recipientsOf(value: Prisma.JsonValue): StoredRecipient[] { + return storedRecipients.parse(value).flatMap((entry) => { + const parsed = storedRecipient.safeParse(entry); + return parsed.success ? [parsed.data] : []; }); } diff --git a/apps/api/src/mailbox/mailbox.constants.ts b/apps/api/src/mailbox/mailbox.constants.ts index 4e0625809..a9228d8b9 100644 --- a/apps/api/src/mailbox/mailbox.constants.ts +++ b/apps/api/src/mailbox/mailbox.constants.ts @@ -37,14 +37,14 @@ export function isMicrosoftSyncSource( return (MICROSOFT_SYNC_SOURCES as readonly string[]).includes(source); } -export const SCOPE_FOR_SOURCE: Record = { +export const SCOPE_FOR_SOURCE = { calendar: CALENDAR_SCOPE, gmail: GMAIL_SCOPE, outlook: OUTLOOK_MAIL_SCOPE, -}; +} satisfies Record; -export const PROVIDER_FOR_SOURCE: Record = { +export const PROVIDER_FOR_SOURCE = { calendar: GOOGLE_PROVIDER_ID, gmail: GOOGLE_PROVIDER_ID, outlook: MICROSOFT_PROVIDER_ID, -}; +} satisfies Record; diff --git a/apps/api/src/mailbox/participants.ts b/apps/api/src/mailbox/participants.ts index fcb9c1dc8..4da7a5ed7 100644 --- a/apps/api/src/mailbox/participants.ts +++ b/apps/api/src/mailbox/participants.ts @@ -188,10 +188,12 @@ export function dominantDomain( return best; } -export function splitName( - name: string | null, - email: string, -): { firstName: string; lastName: string | null } { +export type PersonName = { + firstName: string; + lastName: string | null; +}; + +export function splitName(name: string | null, email: string): PersonName { const cleaned = name?.trim().replace(/\s+/g, " ") ?? ""; if (cleaned && !cleaned.includes("@")) { diff --git a/apps/api/src/mailbox/sync-state.service.ts b/apps/api/src/mailbox/sync-state.service.ts index d669983be..ca56462b4 100644 --- a/apps/api/src/mailbox/sync-state.service.ts +++ b/apps/api/src/mailbox/sync-state.service.ts @@ -2,6 +2,7 @@ import { type Db, GoogleSyncStatus, type MailboxSyncModel as MailboxSync, + type Prisma, } from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -25,9 +26,10 @@ export class SyncStateService { userId: string, sources?: readonly SyncSource[], ): Promise { - return this.db.mailboxSync.findMany({ - where: { userId, ...(sources ? { source: { in: [...sources] } } : {}) }, - }); + const where: Prisma.MailboxSyncWhereInput = { userId }; + if (sources) where.source = { in: [...sources] }; + + return this.db.mailboxSync.findMany({ where }); } async due(now: Date): Promise { @@ -164,9 +166,10 @@ export class SyncStateService { } async remove(userId: string, source?: SyncSource): Promise { - await this.db.mailboxSync.deleteMany({ - where: { userId, ...(source ? { source } : {}) }, - }); + const where: Prisma.MailboxSyncWhereInput = { userId }; + if (source) where.source = source; + + await this.db.mailboxSync.deleteMany({ where }); } } diff --git a/apps/api/src/mailbox/thread-writer.service.ts b/apps/api/src/mailbox/thread-writer.service.ts index 57d131b2a..9b06692c6 100644 --- a/apps/api/src/mailbox/thread-writer.service.ts +++ b/apps/api/src/mailbox/thread-writer.service.ts @@ -172,17 +172,15 @@ export class ThreadWriterService { const firstMessageAt = stats._min.sentAt ?? parsed.sentAt; const lastMessageAt = stats._max.sentAt ?? parsed.sentAt; - await tx.emailThread.update({ - where: { id: record.id }, - data: { - messageCount: stats._count._all, - firstMessageAt, - lastMessageAt, - ...(parsed.sentAt <= firstMessageAt - ? { subject: parsed.subject } - : {}), - }, - }); + const data: Prisma.EmailThreadUpdateInput = { + messageCount: stats._count._all, + firstMessageAt, + lastMessageAt, + }; + + if (parsed.sentAt <= firstMessageAt) data.subject = parsed.subject; + + await tx.emailThread.update({ where: { id: record.id }, data }); return this.project(tx, record.id, row.userId, { subject: parsed.subject ?? "(no subject)", @@ -204,12 +202,12 @@ export class ThreadWriterService { } private async storedElsewhere( - error: unknown, + cause: unknown, rfcMessageId: string, ): Promise { const duplicate = - error instanceof PrismaNamespace.PrismaClientKnownRequestError && - error.code === "P2002"; + cause instanceof PrismaNamespace.PrismaClientKnownRequestError && + cause.code === "P2002"; if (!duplicate) return false; const winner = await this.db.emailMessage.findFirst({ diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f6e3bead5..af118dec5 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -15,10 +15,10 @@ async function bootstrap() { }); } -void bootstrap().catch((error: unknown) => { +void bootstrap().catch((cause: unknown) => { new Logger("Bootstrap").fatal( { message: "API failed to start" }, - error instanceof Error ? error.stack : String(error), + cause instanceof Error ? cause.stack : String(cause), ); process.exit(1); }); diff --git a/apps/api/src/settings/model-catalog.service.ts b/apps/api/src/settings/model-catalog.service.ts index 5f7c515d4..13ebcbbff 100644 --- a/apps/api/src/settings/model-catalog.service.ts +++ b/apps/api/src/settings/model-catalog.service.ts @@ -1,6 +1,7 @@ import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Inject, Injectable, Logger } from "@nestjs/common"; import type { Cache } from "cache-manager"; +import { z } from "zod"; const CATALOG_URL = "https://ai-gateway.vercel.sh/v1/models"; @@ -18,29 +19,47 @@ export interface CatalogModel { pricing: { input: number; output: number } | null; } -interface GatewayModel { - id?: unknown; - name?: unknown; - owned_by?: unknown; - type?: unknown; - tags?: unknown; - context_window?: unknown; - pricing?: { input?: unknown; output?: unknown } | null; -} +const gatewayRate = z + .union([z.number(), z.string()]) + .transform((value) => Number(value)) + .refine((value) => Number.isFinite(value)) + .nullable() + .catch(null); + +const gatewayModel = z.object({ + id: z.string(), + name: z.string().catch(""), + owned_by: z.string().catch(""), + type: z.string().catch(""), + tags: z.array(z.json()).catch([]), + context_window: z.number(), + pricing: z + .object({ input: gatewayRate, output: gatewayRate }) + .nullable() + .catch(null), +}); + +type GatewayModel = z.infer; + +const gatewayCatalog = z + .object({ data: z.array(z.json()).catch([]) }) + .catch({ data: [] }); -function rate(value: unknown): number | null { - const parsed = typeof value === "string" ? Number(value) : value; - return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null; +function usable(model: GatewayModel): boolean { + return model.type === "language" && model.tags.includes("tool-use"); } -function usable(model: GatewayModel): boolean { - const tags = Array.isArray(model.tags) ? model.tags : []; - return ( - typeof model.id === "string" && - model.type === "language" && - tags.includes("tool-use") && - typeof model.context_window === "number" - ); +function toCatalogModel(model: GatewayModel): CatalogModel { + const input = model.pricing?.input ?? null; + const output = model.pricing?.output ?? null; + + return { + id: model.id, + name: model.name || model.id, + provider: model.owned_by || (model.id.split("/")[0] ?? model.id), + contextWindowTokens: model.context_window, + pricing: input !== null && output !== null ? { input, output } : null, + }; } @Injectable() @@ -80,26 +99,13 @@ export class ModelCatalogService { return null; } - const body = (await response.json()) as { data?: unknown }; - const rows = Array.isArray(body.data) - ? (body.data as GatewayModel[]) - : []; - - const models = rows.filter(usable).map((model): CatalogModel => { - const id = model.id as string; - const input = rate(model.pricing?.input); - const output = rate(model.pricing?.output); - - return { - id, - name: typeof model.name === "string" && model.name ? model.name : id, - provider: - typeof model.owned_by === "string" && model.owned_by - ? model.owned_by - : (id.split("/")[0] ?? id), - contextWindowTokens: model.context_window as number, - pricing: input !== null && output !== null ? { input, output } : null, - }; + const body = gatewayCatalog.parse(await response.json()); + + const models = body.data.flatMap((entry) => { + const parsed = gatewayModel.safeParse(entry); + return parsed.success && usable(parsed.data) + ? [toCatalogModel(parsed.data)] + : []; }); models.sort( diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index ebb6e1b8e..71c55e24d 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -133,10 +133,10 @@ export class SettingsService { }); } }) - .catch((error: unknown) => { + .catch((cause: unknown) => { this.logger.warn( { message: "Could not queue the waiting research" }, - error instanceof Error ? error.stack : String(error), + cause instanceof Error ? cause.stack : String(cause), ); }); diff --git a/apps/api/src/slack/slack-connection.service.ts b/apps/api/src/slack/slack-connection.service.ts index b58e3d4d5..ab8d63213 100644 --- a/apps/api/src/slack/slack-connection.service.ts +++ b/apps/api/src/slack/slack-connection.service.ts @@ -3,7 +3,7 @@ import { isSlackConfigured, WORKSPACE_ID, } from "@crm/auth"; -import type { Db } from "@crm/db"; +import type { Db, Prisma } from "@crm/db"; import { schemas } from "@crm/validation"; import { BadRequestException, @@ -177,17 +177,16 @@ export class SlackConnectionService { const take = input.limit ?? SLACK.channels.pageSize; const needle = input.query?.trim() ?? ""; + const where: Prisma.SlackChannelWhereInput = { available: true }; + if (needle) where.name = { contains: needle, mode: "insensitive" }; + const [rows, grant, sync] = await Promise.all([ this.db.slackChannel.findMany({ - where: { - available: true, - ...(needle - ? { name: { contains: needle, mode: "insensitive" } } - : {}), - }, + where, orderBy: [{ isMember: "desc" }, { name: "asc" }, { id: "asc" }], take: take + 1, - ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + cursor: input.cursor ? { id: input.cursor } : undefined, + skip: input.cursor ? 1 : undefined, select: { id: true, name: true, diff --git a/apps/api/src/sso/sso.service.ts b/apps/api/src/sso/sso.service.ts index 6537ba08d..bdb09d26c 100644 --- a/apps/api/src/sso/sso.service.ts +++ b/apps/api/src/sso/sso.service.ts @@ -19,8 +19,14 @@ import { Logger, } from "@nestjs/common"; import { APIError } from "better-auth/api"; +import { z } from "zod"; import { InjectDatabase } from "../database/database.constants"; -import { type ListResult, paginate, resolveOrderBy } from "../trpc/list-input"; +import { + type ListResult, + type OrderByColumns, + paginate, + resolveOrderBy, +} from "../trpc/list-input"; import type { DeleteSsoProviderInput, RegisterSsoProviderInput, @@ -65,23 +71,20 @@ type ProviderRow = Prisma.SsoProviderGetPayload<{ select: typeof PROVIDER_SELECT; }>; -const SORTABLE: Record< - string, - (dir: "asc" | "desc") => Prisma.SsoProviderOrderByWithRelationInput -> = { +const SORTABLE: OrderByColumns = { providerId: (dir) => ({ providerId: dir }), domain: (dir) => ({ domain: dir }), issuer: (dir) => ({ issuer: dir }), }; -const STATUS_BY_CODE: Record = { - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - NOT_FOUND: 404, - CONFLICT: 409, - UNPROCESSABLE_ENTITY: 400, -}; +const STATUS_BY_CODE = new Map([ + ["BAD_REQUEST", 400], + ["UNAUTHORIZED", 401], + ["FORBIDDEN", 403], + ["NOT_FOUND", 404], + ["CONFLICT", 409], + ["UNPROCESSABLE_ENTITY", 400], +]); function splitDomains(value: string): string[] { return value @@ -96,27 +99,28 @@ function splitDomains(value: string): string[] { .filter(Boolean); } -function lastFour(clientId: unknown): string | null { - return typeof clientId === "string" && clientId.length >= 4 - ? clientId.slice(-4) - : null; +const oidcConfig = z + .object({ clientId: z.string().catch("") }) + .catch({ clientId: "" }); + +type OidcConfig = z.infer; + +function lastFour(clientId: string): string | null { + return clientId.length >= 4 ? clientId.slice(-4) : null; } -function parseConfig(value: string | null): Record | null { +function readOidcConfig(value: string | null): OidcConfig | null { if (!value) return null; try { - const parsed: unknown = JSON.parse(value); - return parsed && typeof parsed === "object" - ? (parsed as Record) - : null; + return oidcConfig.parse(JSON.parse(value)); } catch { return null; } } function toProvider(row: ProviderRow): SsoProvider { - const oidc = parseConfig(row.oidcConfig); + const oidc = readOidcConfig(row.oidcConfig); return { providerId: row.providerId, @@ -270,11 +274,11 @@ export class SsoService { } catch (error) { if (error instanceof APIError) { const status = - STATUS_BY_CODE[error.body?.code ?? ""] ?? error.statusCode; + STATUS_BY_CODE.get(error.body?.code ?? "") ?? error.statusCode; throw new HttpException( error.body?.message ?? "The identity provider could not be saved.", - typeof status === "number" ? status : 400, + status, ); } diff --git a/apps/api/src/telemetry/rollup.service.ts b/apps/api/src/telemetry/rollup.service.ts index 928cd990c..20ee5b663 100644 --- a/apps/api/src/telemetry/rollup.service.ts +++ b/apps/api/src/telemetry/rollup.service.ts @@ -587,14 +587,16 @@ function isSet(name: string): boolean { return Boolean(process.env[name]?.trim()); } -function byKind(rows: Counted[]): Record { +type CountsByKey = Record; + +function byKind(rows: Counted[]): CountsByKey { return merge( rows.map((row) => ({ ...row, key: permittedTaskKind(row.key) })), ); } -function merge(rows: Counted[]): Record { - const counts: Record = {}; +function merge(rows: Counted[]): CountsByKey { + const counts: CountsByKey = {}; for (const row of rows) { counts[row.key] = (counts[row.key] ?? 0) + row.count; @@ -603,12 +605,9 @@ function merge(rows: Counted[]): Record { return counts; } -function countsOf( - rows: Counted[], - keys: readonly string[], -): Record { +function countsOf(rows: Counted[], keys: readonly string[]): CountsByKey { const merged = merge(rows); - const complete: Record = {}; + const complete: CountsByKey = {}; for (const key of keys) complete[key] = merged[key] ?? 0; diff --git a/apps/api/src/tracking/tracking-filing.service.ts b/apps/api/src/tracking/tracking-filing.service.ts index ceca0b122..16aa5427e 100644 --- a/apps/api/src/tracking/tracking-filing.service.ts +++ b/apps/api/src/tracking/tracking-filing.service.ts @@ -20,10 +20,12 @@ import { } from "../mailbox/participants"; import { TrackingCounterService } from "./tracking-counter.service"; +type TouchColumns = Record; + function columns( touch: Touch | undefined, prefix: "first" | "last", -): Record { +): TouchColumns { if (!touch) return {}; return { @@ -144,12 +146,12 @@ export class TrackingFilingService { } private async raced( - error: unknown, + cause: unknown, email: string, ): Promise<{ id: string } | null> { if ( - !(error instanceof Prisma.PrismaClientKnownRequestError) || - error.code !== "P2002" + !(cause instanceof Prisma.PrismaClientKnownRequestError) || + cause.code !== "P2002" ) { return null; } diff --git a/apps/api/src/tracking/tracking-ingest.service.ts b/apps/api/src/tracking/tracking-ingest.service.ts index bffd39eac..e4f265b1a 100644 --- a/apps/api/src/tracking/tracking-ingest.service.ts +++ b/apps/api/src/tracking/tracking-ingest.service.ts @@ -13,6 +13,7 @@ import { type TrackingConfig, } from "@crm/db/tracking"; import { Injectable, Logger } from "@nestjs/common"; +import { z } from "zod"; import { normalizeEmail } from "../crm/values"; import { InjectDatabase } from "../database/database.constants"; import { TrackingConfigService } from "./tracking-config.service"; @@ -35,6 +36,21 @@ const CARD = /^[0-9 -]{12,25}$/; const ADDRESS = /^[^\s@]+@[^\s@.]+\.[^\s@]+$/; +const fieldText = z.string().nullable().catch(null); + +export type FormFields = Record; + +type StoredTouch = { + source: string; + medium: string; + campaign: string | null; + term: string | null; + content: string | null; + referrer: string | null; + landing: string | null; + at: string; +}; + export interface IncomingEvent { type: string; host: string; @@ -42,7 +58,7 @@ export interface IncomingEvent { referrer?: string; label?: string; at?: number; - fields?: Record; + fields?: FormFields; touch?: RawTouch; firstTouch?: RawTouch; } @@ -250,7 +266,7 @@ function arriving(touch: RawTouch): RawTouch { }; } -function stored(touch: Touch): Record { +function stored(touch: Touch): StoredTouch { return { source: touch.source, medium: touch.medium, @@ -267,7 +283,7 @@ function scripted(events: IncomingEvent[]): boolean { if (events.length < 3) return false; const stamps = events.flatMap((event) => - typeof event.at === "number" && Number.isFinite(event.at) ? [event.at] : [], + event.at !== undefined && Number.isFinite(event.at) ? [event.at] : [], ); if (stamps.length !== events.length) return false; @@ -277,7 +293,7 @@ function scripted(events: IncomingEvent[]): boolean { function occurredAt(at: number | undefined): Date { const now = Date.now(); - if (typeof at !== "number" || !Number.isFinite(at)) return new Date(now); + if (at === undefined || !Number.isFinite(at)) return new Date(now); const bounded = Math.min(Math.max(at, now - 86_400_000), now); @@ -289,28 +305,27 @@ function trim(value: string, max: number): string { } function sanitizeId(value: string | undefined): string | null { - if (typeof value !== "string") return null; - - const trimmed = value.trim(); + const trimmed = fieldText.parse(value)?.trim() ?? ""; return /^[a-zA-Z0-9_-]{8,64}$/.test(trimmed) ? trimmed : null; } -function clean(fields: Record): Record { - const kept: Record = {}; +function clean(fields: FormFields): FormFields { + const kept: FormFields = {}; for (const [key, value] of Object.entries(fields).slice(0, 40)) { - if (typeof value !== "string") continue; + const text = fieldText.parse(value); + if (text === null) continue; if (SENSITIVE.test(key)) continue; - if (CARD.test(value.trim())) continue; + if (CARD.test(text.trim())) continue; - kept[trim(key, 64)] = trim(value, 512); + kept[trim(key, 64)] = trim(text, 512); } return kept; } -function emailFrom(fields: Record): string | null { +function emailFrom(fields: FormFields): string | null { for (const [key, value] of Object.entries(fields)) { if (!/mail/i.test(key)) continue; const email = address(value); @@ -331,7 +346,7 @@ function address(value: string): string | null { return email && ADDRESS.test(email) ? email : null; } -function nameFrom(fields: Record): string | null { +function nameFrom(fields: FormFields): string | null { const first = pick(fields, /^(first[\s_-]?name|fname|given)/i); const last = pick(fields, /^(last[\s_-]?name|lname|surname|family)/i); @@ -340,7 +355,7 @@ function nameFrom(fields: Record): string | null { return pick(fields, /^(full[\s_-]?name|name)$/i) ?? pick(fields, /name/i); } -function pick(fields: Record, pattern: RegExp): string | null { +function pick(fields: FormFields, pattern: RegExp): string | null { for (const [key, value] of Object.entries(fields)) { if (pattern.test(key) && value.trim()) return value.trim(); } diff --git a/apps/api/src/tracking/tracking.controller.ts b/apps/api/src/tracking/tracking.controller.ts index 1c96016c6..21ccbf238 100644 --- a/apps/api/src/tracking/tracking.controller.ts +++ b/apps/api/src/tracking/tracking.controller.ts @@ -21,6 +21,7 @@ import { import { ConfigService } from "@nestjs/config"; import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; import type { Response } from "express"; +import { z } from "zod"; import type { EnvironmentVariables } from "../config/env.validation"; import { InjectDatabase } from "../database/database.constants"; import { TrackingConfigService } from "./tracking-config.service"; @@ -35,6 +36,18 @@ const SWEEP_BATCH = 10_000; const MAX_SWEEP_PASSES = 50; +const parsedBody = z + .union([ + z.string().transform((text) => ({ text, json: null })), + z + .union([z.array(z.json()), z.looseObject({})]) + .transform((json) => ({ text: null, json })), + ]) + .nullable() + .catch(null); + +const trackingRequest = z.object({ body: parsedBody }).catch({ body: null }); + @Controller("api/t") export class TrackingController { private readonly logger = new Logger(TrackingController.name); @@ -203,12 +216,14 @@ async function read( request: IncomingMessage, limit: number, ): Promise { - const existing = (request as { body?: unknown }).body; - if (typeof existing === "string") { - return existing.length > limit ? null : existing; - } - if (existing && typeof existing === "object") { - return JSON.stringify(existing); + const existing = trackingRequest.parse(request).body; + + if (existing !== null) { + if (existing.text !== null) { + return existing.text.length > limit ? null : existing.text; + } + + return JSON.stringify(existing.json); } return new Promise((resolve) => { diff --git a/apps/api/src/trpc/error-formatter.ts b/apps/api/src/trpc/error-formatter.ts index f0855e92c..6bcb8d134 100644 --- a/apps/api/src/trpc/error-formatter.ts +++ b/apps/api/src/trpc/error-formatter.ts @@ -1,9 +1,21 @@ import type { TRPCDefaultErrorShape, TRPCErrorFormatter } from "@trpc/server"; +import { z } from "zod"; -interface Issue { - message?: unknown; - path?: unknown; -} +const issueShape = z + .object({ + message: z.string().catch(""), + path: z.array(z.unknown()).catch([]), + }) + .catch({ message: "", path: [] }); + +type Issue = z.infer; + +const pathSegment = z.string().nullable().catch(null); + +const failedParse = z + .object({ issues: z.array(issueShape).min(1) }) + .nullable() + .catch(null); /** * tRPC stringifies a failed input parse into the whole `ZodError`, so a form @@ -16,29 +28,23 @@ interface Issue { * matching and put the JSON back on screen. */ function issuesIn(cause: unknown): Issue[] | null { - if (typeof cause !== "object" || cause === null) return null; - - const issues = (cause as { issues?: unknown }).issues; - - return Array.isArray(issues) && issues.length > 0 - ? (issues as Issue[]) - : null; + return failedParse.parse(cause)?.issues ?? null; } function sentence(issue: Issue): string | null { - if (typeof issue.message !== "string" || issue.message.trim() === "") { - return null; - } - const message = issue.message.trim(); + if (message === "") return null; // Zod's own defaults ("Required", "Invalid input") name nothing, so they are // only useful with the field in front of them. Ours are whole sentences. if (/[.!?]$/.test(message)) return message; - const field = Array.isArray(issue.path) - ? issue.path.filter((part) => typeof part === "string").at(-1) - : undefined; + const field = issue.path + .flatMap((part) => { + const segment = pathSegment.parse(part); + return segment === null ? [] : [segment]; + }) + .at(-1); return field ? `${field}: ${message}` : message; } diff --git a/apps/api/src/trpc/list-input.ts b/apps/api/src/trpc/list-input.ts index e2b46fc95..e207d96eb 100644 --- a/apps/api/src/trpc/list-input.ts +++ b/apps/api/src/trpc/list-input.ts @@ -10,7 +10,9 @@ export const listInput = z.object({ export type ListInput = z.infer; -type FacetCounts = Record>; +export type FacetCount = Record; + +type FacetCounts = Record; export type ListResult = { rows: TRow[]; @@ -18,19 +20,27 @@ export type ListResult = { facetCounts: FacetCounts; }; -export function paginate(input: Pick): { +export type Page = { skip: number; take: number; -} { +}; + +export function paginate(input: Pick): Page { return { skip: (input.page - 1) * input.pageSize, take: input.pageSize, }; } +export type SortDirection = ListInput["dir"]; + +export interface OrderByColumns { + [column: string]: (dir: SortDirection) => TOrderBy; +} + export function resolveOrderBy( input: Pick, - columns: Record TOrderBy>, + columns: OrderByColumns, fallback: TOrderBy, ): TOrderBy { const column = columns[input.sort]; @@ -42,8 +52,8 @@ export function countsByKey< TGroup extends { _count: { _all: number } } & { [K in TKey]?: string | null; }, ->(groups: TGroup[], key: TKey, nullKey?: string): Record { - const counts: Record = {}; +>(groups: TGroup[], key: TKey, nullKey?: string): FacetCount { + const counts: FacetCount = {}; for (const group of groups) { const value = group[key] ?? nullKey; diff --git a/apps/api/src/workspace/workspace.service.ts b/apps/api/src/workspace/workspace.service.ts index e498f6d85..8e49e6f29 100644 --- a/apps/api/src/workspace/workspace.service.ts +++ b/apps/api/src/workspace/workspace.service.ts @@ -24,6 +24,7 @@ import { countsByKey, FACET_ALL, type ListResult, + type OrderByColumns, paginate, resolveOrderBy, } from "../trpc/list-input"; @@ -65,10 +66,7 @@ const MEMBER_SELECT = { type MemberRow = Prisma.MemberGetPayload<{ select: typeof MEMBER_SELECT }>; -const SORTABLE: Record< - string, - (dir: Prisma.SortOrder) => Prisma.MemberOrderByWithRelationInput -> = { +const SORTABLE: OrderByColumns = { name: (dir) => ({ user: { name: dir } }), email: (dir) => ({ user: { email: dir } }), role: (dir) => ({ role: dir }), diff --git a/apps/api/test/agent-runs.spec.ts b/apps/api/test/agent-runs.spec.ts index 024b59529..22d4d8c40 100644 --- a/apps/api/test/agent-runs.spec.ts +++ b/apps/api/test/agent-runs.spec.ts @@ -582,7 +582,10 @@ describe("cancelling a run", () => { const realFetch = globalThis.fetch; const realSecret = process.env.AGENT_BRIDGE_SECRET; process.env.AGENT_BRIDGE_SECRET = "run-cancel-test"; - globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + globalThis.fetch = (async ( + _url: string | URL | Request, + init?: RequestInit, + ) => { const body = JSON.parse(String(init?.body)) as { runId: string }; asked.push(body.runId); return new Response(null, { status }); diff --git a/apps/api/test/conversation-sharing.spec.ts b/apps/api/test/conversation-sharing.spec.ts index c1443504c..75f1f60dc 100644 --- a/apps/api/test/conversation-sharing.spec.ts +++ b/apps/api/test/conversation-sharing.spec.ts @@ -2,9 +2,14 @@ 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 { z } from "zod"; import { ConversationSharingService } from "../src/conversations/conversation-sharing.service"; import { ConversationsService } from "../src/conversations/conversations.service"; +const record = z.record(z.string(), z.unknown()).catch({}); + +const list = z.array(z.unknown()).catch([]); + const suffix = crypto.randomUUID(); const userId = `share-user-${suffix}`; const outsiderId = `share-outsider-${suffix}`; @@ -173,8 +178,8 @@ describe("conversation sharing", () => { 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]); + const message = record.parse(shared.submissions[0]?.message); + const attachment = record.parse(list.parse(message.attachments)[0]); expect(attachment.previewUrl).toBe( `/api/conversations/attachments/${attachmentId}?share=${encodeURIComponent(token)}`, ); @@ -214,13 +219,3 @@ describe("conversation sharing", () => { 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 de0d3ab11..25c99ca66 100644 --- a/apps/api/test/conversations.spec.ts +++ b/apps/api/test/conversations.spec.ts @@ -2,6 +2,7 @@ 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 { z } from "zod"; import { builderConversationCreateInput, conversationListInput, @@ -9,6 +10,12 @@ import { } from "../src/conversations/conversations.contracts"; import { ConversationsService } from "../src/conversations/conversations.service"; +const record = z.record(z.string(), z.unknown()).catch({}); + +const list = z.array(z.unknown()).catch([]); + +const text = z.string().nullable().catch(null); + const suffix = process.env.TEST_RUN_ID ?? "conversations-spec"; const email = `conversation.subject.${suffix}@example.test`; const userId = `user-${suffix}`; @@ -449,8 +456,10 @@ describe("ConversationsService", () => { const submission = detail.submissions.find( (row) => row.clientRequestId === input.clientRequestId, ); - const message = recordOf(submission?.message); - const attachments = arrayOf(message.attachments).map(recordOf); + const message = record.parse(submission?.message); + const attachments = list + .parse(message.attachments) + .map((entry) => record.parse(entry)); expect(JSON.stringify(message)).not.toContain("contentBase64"); expect(attachments).toEqual([ @@ -467,8 +476,8 @@ describe("ConversationsService", () => { previewUrl: null, }), ]); - const imageId = attachments[0]?.id; - if (typeof imageId !== "string") throw new Error("Missing attachment id"); + const imageId = text.parse(attachments[0]?.id); + if (imageId === null) throw new Error("Missing attachment id"); const image = await service.attachment(imageId, userId); expect(Buffer.from(image.content)).toEqual(imageBytes); expect(image).toMatchObject({ @@ -515,9 +524,9 @@ describe("ConversationsService", () => { userId, ); const original = await service.builderById(conversation.id, userId); - const originalMessage = recordOf(original.submissions[0]?.message); - const originalAttachment = recordOf( - arrayOf(originalMessage.attachments)[0], + const originalMessage = record.parse(original.submissions[0]?.message); + const originalAttachment = record.parse( + list.parse(originalMessage.attachments)[0], ); await service.submitBuilder( @@ -541,8 +550,10 @@ describe("ConversationsService", () => { ); const detail = await service.builderById(conversation.id, userId); - const retryMessage = recordOf(detail.submissions.at(-1)?.message); - const retryAttachment = recordOf(arrayOf(retryMessage.attachments)[0]); + const retryMessage = record.parse(detail.submissions.at(-1)?.message); + const retryAttachment = record.parse( + list.parse(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); @@ -578,8 +589,8 @@ describe("ConversationsService", () => { userId, ); const sourceDetail = await service.builderById(source.id, userId); - const sourceMessage = recordOf(sourceDetail.submissions[0]?.message); - const attachment = recordOf(arrayOf(sourceMessage.attachments)[0]); + const sourceMessage = record.parse(sourceDetail.submissions[0]?.message); + const attachment = record.parse(list.parse(sourceMessage.attachments)[0]); let submitError: unknown; try { @@ -842,13 +853,3 @@ describe("ConversationsService", () => { ).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/error-formatter.spec.ts b/apps/api/test/error-formatter.spec.ts index 6cd27af78..f6126cddb 100644 --- a/apps/api/test/error-formatter.spec.ts +++ b/apps/api/test/error-formatter.spec.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { setResearchKeyInput } from "../src/settings/settings.contracts"; import { readableInputError } from "../src/trpc/error-formatter"; -const causeOf = (schema: z.ZodType, value: unknown) => { +const causeOf = (schema: z.ZodType, value: z.core.util.JSONType) => { const result = schema.safeParse(value); if (result.success) throw new Error("expected the parse to fail"); return result.error; diff --git a/apps/api/test/logging.spec.ts b/apps/api/test/logging.spec.ts index 0367b66ba..20664959e 100644 --- a/apps/api/test/logging.spec.ts +++ b/apps/api/test/logging.spec.ts @@ -121,11 +121,13 @@ describe("request context", () => { }); }); +type MiddlewareRun = { + requestId: string; + seen: string | undefined; +}; + describe("RequestLoggerMiddleware", () => { - function run(headers: Record): { - requestId: string; - seen: string | undefined; - } { + function run(headers: Record): MiddlewareRun { const middleware = new RequestLoggerMiddleware(); let requestId = ""; let seen: string | undefined; diff --git a/apps/api/test/mailbox-api-client.spec.ts b/apps/api/test/mailbox-api-client.spec.ts index 89315ae9f..256881941 100644 --- a/apps/api/test/mailbox-api-client.spec.ts +++ b/apps/api/test/mailbox-api-client.spec.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; +import type { z } from "zod"; import { MailboxApiClient } from "../src/mailbox/mailbox-api.client"; const realFetch = globalThis.fetch; @@ -9,7 +10,7 @@ afterEach(() => { function stub( status: number, - body: unknown, + body: z.core.util.JSONType, headers: Record = {}, ): void { globalThis.fetch = (async () => diff --git a/apps/api/test/outlook-sync.spec.ts b/apps/api/test/outlook-sync.spec.ts index 89b33985b..38b2adb2c 100644 --- a/apps/api/test/outlook-sync.spec.ts +++ b/apps/api/test/outlook-sync.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { MailboxSyncModel as MailboxSync } from "@crm/db"; +import type { SyncSource } from "../src/mailbox/mailbox.constants"; import type { MailboxTokenService } from "../src/mailbox/mailbox-token.service"; import type { SyncStateService } from "../src/mailbox/sync-state.service"; import type { @@ -18,6 +19,13 @@ type NotOk = const ok = (data: T): Ok => ({ outcome: "ok", data }); +type StoreOptions = { mailbox: string; origin: SyncSource }; + +type GraphPage = { + value: GraphMessage[]; + "@odata.nextLink"?: string; +}; + const row = { id: "sync-1", userId: "user-1", @@ -51,6 +59,13 @@ function harness(options: { const pages = options.pages ?? [[]]; let index = 0; + const page = (at: number): GraphPage => { + const body: GraphPage = { value: pages[at] ?? [] }; + if (at + 1 < pages.length) body["@odata.nextLink"] = `next-${at + 1}`; + + return body; + }; + const graph = { async me() { if (options.meDelayMs) { @@ -66,19 +81,11 @@ function harness(options: { }, async listMessages() { index = 0; - return ok({ - value: pages[0] ?? [], - ...(pages.length > 1 ? { "@odata.nextLink": "next-1" } : {}), - }); + return ok(page(0)); }, async nextPage() { index += 1; - return ok({ - value: pages[index] ?? [], - ...(index + 1 < pages.length - ? { "@odata.nextLink": `next-${index + 1}` } - : {}), - }); + return ok(page(index)); }, } as unknown as GraphClient; @@ -109,7 +116,11 @@ function harness(options: { async context() { return {}; }, - async store(_row: MailboxSync, _options: unknown, parsed: IncomingMessage) { + async store( + _row: MailboxSync, + _options: StoreOptions, + parsed: IncomingMessage, + ) { stored.push(parsed); return true; }, diff --git a/apps/api/test/slack-channels.spec.ts b/apps/api/test/slack-channels.spec.ts index d9e6ef0e8..558a22e51 100644 --- a/apps/api/test/slack-channels.spec.ts +++ b/apps/api/test/slack-channels.spec.ts @@ -6,7 +6,10 @@ const realFetch = globalThis.fetch; const realSecret = process.env.AGENT_BRIDGE_SECRET; function agentAnswers(status: number, body: string | null) { - globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => + globalThis.fetch = (async ( + _url: string | URL | Request, + _init?: RequestInit, + ) => new Response(body, { status, headers: { "content-type": "application/json" }, diff --git a/apps/api/test/sso.spec.ts b/apps/api/test/sso.spec.ts index 46486d7ec..6f28a3ccb 100644 --- a/apps/api/test/sso.spec.ts +++ b/apps/api/test/sso.spec.ts @@ -20,8 +20,10 @@ const LIST = { pageSize: 25, }; +type Seen = { providerWhere?: unknown }; + function service(role: string | null, rows: Row[] = []) { - const seen: { providerWhere?: unknown } = {}; + const seen: Seen = {}; const db = { member: { diff --git a/packages/validation/package.json b/packages/validation/package.json index a791a03ea..105f3b293 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./activity-meta": "./src/activity-meta.ts", "./agent-events": "./src/agent-events.ts", "./agent-manifest": "./src/agent-manifest.ts", "./builder-question": "./src/builder-question.ts", diff --git a/packages/validation/src/activity-meta.ts b/packages/validation/src/activity-meta.ts new file mode 100644 index 000000000..fb9c89ea5 --- /dev/null +++ b/packages/validation/src/activity-meta.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const activityMetaFields = z.record(z.string(), z.json()); + +export type ActivityMetaFields = z.infer; + +export const activityMeta = activityMetaFields.nullable().catch(null); + +export type ActivityMeta = z.infer; diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index fb3c1f763..e0d53ce64 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,4 +1,5 @@ import type { ZodType, z } from "zod"; +import * as activityMeta from "./activity-meta"; import * as agentEvents from "./agent-events"; import * as agentManifest from "./agent-manifest"; import * as agents from "./agents"; @@ -8,6 +9,7 @@ import * as eveTool from "./eve-tool"; import * as slack from "./slack"; export const schemas = { + activityMeta, agentEvents, agentManifest, agents, @@ -17,6 +19,7 @@ export const schemas = { slack, } as const; +export type { ActivityMeta, ActivityMetaFields } from "./activity-meta"; export type { CrmEventTask } from "./agent-events"; export type { AgentActionType, From 5a52210f5fa3da79e6871774ccca819bd28545b7 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:27:52 -0700 Subject: [PATCH 5/7] CMP-82 refactor: parse channel, hook and tool boundaries in the agent The channel and audit hook still carried their own copies of the recordOf helper the lib pass deleted. Route bodies, receive targets and eve event data are parsed where they arrive, reusing the eve schemas rather than restating them. The settle paths are byte-identical. docs/agent.md records a token-prefix mistake that silently stopped every task reaching finishedAt, so taskFromToken and the channel handlers were left structurally untouched and are still covered by crm-token and drain. One cast survives in the audit hook, narrowed from object to Prisma.InputJsonObject, and a second one at the event write is gone. It cannot become a parse: eve sets details and error to undefined on the failure events, z.json rejects an undefined property value, so a whole blob parse would catch and silently blank the audit trail for exactly those events. --- apps/agent/agent/channels/crm.ts | 77 ++++++++-------- apps/agent/agent/hooks/activity.ts | 10 ++- apps/agent/agent/hooks/audit.ts | 47 +++++----- apps/agent/agent/hooks/telemetry.ts | 14 +-- apps/agent/agent/instructions/task.ts | 31 +++---- apps/agent/agent/tools/enrich_company.ts | 4 +- apps/agent/agent/tools/identify_contact.ts | 2 +- apps/agent/agent/tools/record_fact.ts | 2 +- apps/agent/agent/tools/research_company.ts | 89 +++++++++++-------- apps/agent/evals/agent-builder.eval.ts | 24 ++--- apps/agent/scripts/backfill-brand-images.ts | 23 +++-- apps/agent/test/channel-auth.spec.ts | 22 +++-- .../durable-agent-runtime.integration.spec.ts | 24 +++-- apps/agent/test/e2e/dispatch.e2e.ts | 2 +- apps/agent/test/e2e/e2e-agents.ts | 4 +- apps/agent/test/e2e/slack-join.e2e.ts | 4 +- .../test/slack-people.integration.spec.ts | 21 ++++- 17 files changed, 225 insertions(+), 175 deletions(-) diff --git a/apps/agent/agent/channels/crm.ts b/apps/agent/agent/channels/crm.ts index b8c1920ae..fabb45c45 100644 --- a/apps/agent/agent/channels/crm.ts +++ b/apps/agent/agent/channels/crm.ts @@ -2,7 +2,9 @@ import { timingSafeEqual } from "node:crypto"; import { EnrichmentStatus, Prisma } from "@crm/db"; import { MAX_ATTEMPTS } from "@crm/db/agent-tasks"; import { schemas } from "@crm/validation"; +import { eveTurnFailure } from "@crm/validation/eve-stream"; import { defineChannel, GET, POST } from "eve/channels"; +import { z } from "zod"; import { persistBuilderInputRequest } from "../lib/builder-input"; import { verifyKey } from "../lib/context-dev"; import { @@ -34,6 +36,28 @@ import { completeTask, taskSubject } from "../lib/tasks"; const TASK_MARKER = "task:"; const STALE_QUEUE_MS = DISPATCH.sweep.staleQueueMs; +type InternalDispatchPrincipal = { + readonly authenticator: string; + readonly principalId: string; + readonly principalType: string; +} | null; + +const identifier = z.string().trim().min(1).nullable().catch(null); + +const cancelRunRequest = z.object({ runId: identifier }).catch({ runId: null }); + +const verifyKeyRequest = z + .object({ apiKey: identifier }) + .catch({ apiKey: null }); + +const receiveTarget = z + .object({ + builderSubmissionId: z.string().nullable().catch(null), + runId: z.string().nullable().catch(null), + taskId: z.string().nullable().catch(null), + }) + .catch({ builderSubmissionId: null, runId: null, taskId: null }); + function authorised(request: Request): boolean { const secret = process.env.AGENT_BRIDGE_SECRET?.trim(); if (!secret) return false; @@ -140,10 +164,9 @@ export default defineChannel({ return new Response("Unauthorized", { status: 401 }); } - const body = (await request.json().catch(() => null)) as { - runId?: unknown; - } | null; - const runId = typeof body?.runId === "string" ? body.runId.trim() : null; + const { runId } = cancelRunRequest.parse( + await request.json().catch(() => null), + ); if (!runId) { return Response.json({ error: "No run id was sent." }, { status: 400 }); } @@ -184,12 +207,9 @@ export default defineChannel({ return new Response("Unauthorized", { status: 401 }); } - const body = (await request.json().catch(() => null)) as { - apiKey?: unknown; - } | null; - - const apiKey = - typeof body?.apiKey === "string" ? body.apiKey.trim() : null; + const { apiKey } = verifyKeyRequest.parse( + await request.json().catch(() => null), + ); if (!apiKey) { return Response.json( @@ -249,9 +269,7 @@ export default defineChannel({ async "turn.failed"(data, channel) { const taskId = taskFromToken(channel.continuationToken); const reason = - typeof data === "object" && data && "message" in data - ? String((data as { message: unknown }).message) - : "The agent turn failed."; + eveTurnFailure.parse(data).message ?? "The agent turn failed."; if (taskId) { const subject = await taskSubject(taskId); @@ -357,47 +375,32 @@ export default defineChannel({ }, async receive(input, { send }) { - const builderSubmissionId = - typeof input.target?.builderSubmissionId === "string" - ? input.target.builderSubmissionId - : null; - if (builderSubmissionId) { + const target = receiveTarget.parse(input.target); + if (target.builderSubmissionId) { assertInternalDispatchAuth(input.auth); - return dispatchBuilderSubmission(builderSubmissionId, send); + return dispatchBuilderSubmission(target.builderSubmissionId, send); } - const runId = - typeof input.target?.runId === "string" ? input.target.runId : null; - if (runId) { + if (target.runId) { assertInternalDispatchAuth(input.auth); - return dispatchAgentRun(runId, send); + return dispatchAgentRun(target.runId, send); } - const taskId = - typeof input.target?.taskId === "string" ? input.target.taskId : null; - return send(input.message, { auth: input.auth, - continuationToken: taskId - ? taskToken(taskId) + continuationToken: target.taskId + ? taskToken(target.taskId) : `crm:adhoc:${crypto.randomUUID()}`, }); }, }); -function assertInternalDispatchAuth(value: unknown): void { - const auth = recordOf(value); +function assertInternalDispatchAuth(auth: InternalDispatchPrincipal): void { if ( - auth.authenticator !== "app" || + auth?.authenticator !== "app" || auth.principalType !== "runtime" || auth.principalId !== "eve:app" ) { throw new Error("Internal agent dispatch requires Eve app authentication."); } } - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} diff --git a/apps/agent/agent/hooks/activity.ts b/apps/agent/agent/hooks/activity.ts index ac6e5105a..2e661bd15 100644 --- a/apps/agent/agent/hooks/activity.ts +++ b/apps/agent/agent/hooks/activity.ts @@ -1,7 +1,11 @@ import { defineHook, type HookEvent } from "eve/hooks"; +import { z } from "zod"; type ActionRequest = HookEvent<"actions.requested">["data"]["actions"][number]; type ActionResult = HookEvent<"action.result">["data"]["result"]; +type ActionInput = ActionRequest["input"]; + +const inputText = z.string().nullable().catch(null); const SHOW_CONTENT = process.env.NODE_ENV !== "production"; const MAX_IN_FLIGHT = 256; @@ -16,14 +20,14 @@ function line(symbol: string, text: string): void { console.error(`[agent] ${symbol} ${text}`); } -function preview(input: unknown): string { - if (!SHOW_CONTENT || typeof input !== "object" || input === null) return ""; +function preview(input: ActionInput): string { + if (!SHOW_CONTENT) return ""; const parts: string[] = []; for (const [key, value] of Object.entries(input)) { if (value === null || value === undefined) continue; - const text = typeof value === "string" ? value : JSON.stringify(value); + const text = inputText.parse(value) ?? JSON.stringify(value); parts.push(`${key}=${truncate(text ?? String(value), 48)}`); } diff --git a/apps/agent/agent/hooks/audit.ts b/apps/agent/agent/hooks/audit.ts index 1cb42a0e6..e2b59e9bc 100644 --- a/apps/agent/agent/hooks/audit.ts +++ b/apps/agent/agent/hooks/audit.ts @@ -1,10 +1,29 @@ import { db, Prisma } from "@crm/db"; import { defineHook } from "eve/hooks"; +import { z } from "zod"; import { isTransportOnlyEvent } from "../lib/event-persistence"; import { currentFocus } from "../lib/focus"; import { lockAgentRun } from "../lib/run-state"; import { attribute, purposeOf } from "../lib/session-purpose"; +const finiteNumber = z.number().refine(Number.isFinite).nullable().catch(null); + +const stepUsage = z + .object({ + usage: z + .object({ + inputTokens: finiteNumber, + outputTokens: finiteNumber, + costUsd: finiteNumber, + }) + .catch({ inputTokens: null, outputTokens: null, costUsd: null }), + }) + .catch({ usage: { inputTokens: null, outputTokens: null, costUsd: null } }); + +const completedMessage = z + .object({ message: z.string().nullable().catch(null) }) + .catch({ message: null }); + export default defineHook({ events: { async "*"(event, ctx) { @@ -13,7 +32,9 @@ export default defineHook({ if (!id || isTransportOnlyEvent(event.type)) return; try { - const data = ("data" in event ? (event.data ?? {}) : {}) as object; + const data = ( + "data" in event ? (event.data ?? {}) : {} + ) as Prisma.InputJsonObject; const emittedAt = event.meta?.at ? new Date(event.meta.at) : new Date(); const purpose = purposeOf(ctx); const conversationId = @@ -86,7 +107,7 @@ async function persistRunEvent( tx: Prisma.TransactionClient, eventId: string, type: string, - data: object, + data: Prisma.InputJsonObject, emittedAt: Date, ctx: Parameters[0] & { session: { id: string } }, ) { @@ -128,17 +149,13 @@ async function persistRunEvent( runId, sequence, type, - data: data as Prisma.InputJsonValue, + data, 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 { inputTokens, outputTokens, costUsd } = stepUsage.parse(data).usage; const current = await tx.agentRun.findUniqueOrThrow({ where: { id: runId }, select: { inputTokens: true, outputTokens: true, costUsd: true }, @@ -161,8 +178,8 @@ async function persistRunEvent( } if (type === "message.completed") { - const message = recordOf(data).message; - if (typeof message === "string" && message.trim()) { + const { message } = completedMessage.parse(data); + if (message?.trim()) { await tx.agentRun.updateMany({ where: { id: runId, status: "RUNNING" }, data: { summary: message.slice(0, 1000) }, @@ -174,13 +191,3 @@ async function persistRunEvent( function isRootSession(ctx: Parameters[0]): boolean { return !("parent" in ctx.session) || !ctx.session.parent; } - -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/hooks/telemetry.ts b/apps/agent/agent/hooks/telemetry.ts index d203f847d..da537aebc 100644 --- a/apps/agent/agent/hooks/telemetry.ts +++ b/apps/agent/agent/hooks/telemetry.ts @@ -2,6 +2,13 @@ import { db } from "@crm/db"; import { readAgentModel } from "@crm/db/settings"; import { agentError, modelError } from "@crm/telemetry"; import { defineHook } from "eve/hooks"; +import { z } from "zod"; + +type SessionPrincipal = { + readonly attributes?: Readonly>; +} | null; + +const attributeText = z.string().trim().min(1).nullable().catch(null); let modelId: string | null = null; @@ -27,11 +34,8 @@ const MODEL_CODES = [ "unauthorized", ]; -function taskKind( - auth: { attributes?: Record } | null, -): string | null { - const kind = auth?.attributes?.taskKind; - return typeof kind === "string" && kind.trim() ? kind.trim() : null; +function taskKind(auth: SessionPrincipal): string | null { + return attributeText.parse(auth?.attributes?.taskKind); } function looksLikeModel(code: string): boolean { diff --git a/apps/agent/agent/instructions/task.ts b/apps/agent/agent/instructions/task.ts index e617aab09..36a15096b 100644 --- a/apps/agent/agent/instructions/task.ts +++ b/apps/agent/agent/instructions/task.ts @@ -1,9 +1,19 @@ import { defineDynamic, defineInstructions } from "eve/instructions"; +import { z } from "zod"; 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"; +const attributeText = z.string().trim().min(1).nullable().catch(null); + +const attributeNumber = z + .union([z.string(), z.number()]) + .transform(Number) + .refine(Number.isFinite) + .nullable() + .catch(null); + export default defineDynamic({ events: { "session.started": async (_event, ctx) => { @@ -19,21 +29,21 @@ export default defineDynamic({ } const attributes = ctx.session.auth.current?.attributes ?? {}; - const budget = asNumber(attributes.budget); - const kind = asString(attributes.taskKind); + const budget = attributeNumber.parse(attributes.budget); + const kind = attributeText.parse(attributes.taskKind); if (budget) setBudget(budget); const { markdown, focus } = await sessionPreamble( { - contactId: asString(attributes.contactId), - companyId: asString(attributes.companyId), - dealId: asString(attributes.dealId), + contactId: attributeText.parse(attributes.contactId), + companyId: attributeText.parse(attributes.companyId), + dealId: attributeText.parse(attributes.dealId), }, { dispatched: Boolean(kind), kind, - reason: asString(attributes.reason), + reason: attributeText.parse(attributes.reason), budget, }, ); @@ -71,12 +81,3 @@ export function builderTaskMarkdown( ? `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; -} - -function asNumber(value: unknown): number | null { - const parsed = typeof value === "string" ? Number(value) : value; - return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null; -} diff --git a/apps/agent/agent/tools/enrich_company.ts b/apps/agent/agent/tools/enrich_company.ts index 58fc2acd8..02502f5d0 100644 --- a/apps/agent/agent/tools/enrich_company.ts +++ b/apps/agent/agent/tools/enrich_company.ts @@ -24,9 +24,7 @@ export default defineTool({ return { enriched: false as const, reason: result.reason, - ...(result.retryable === undefined - ? {} - : { retryable: result.retryable }), + retryable: result.retryable, }; } diff --git a/apps/agent/agent/tools/identify_contact.ts b/apps/agent/agent/tools/identify_contact.ts index e31f7d281..c6953fd92 100644 --- a/apps/agent/agent/tools/identify_contact.ts +++ b/apps/agent/agent/tools/identify_contact.ts @@ -44,7 +44,7 @@ export default defineTool({ band: result.band, score: Number(result.score.toFixed(2)), rationale: result.rationale, - ...(result.reason ? { reason: result.reason } : {}), + reason: result.reason || undefined, }; }, }); diff --git a/apps/agent/agent/tools/record_fact.ts b/apps/agent/agent/tools/record_fact.ts index 899400f21..efe6612d7 100644 --- a/apps/agent/agent/tools/record_fact.ts +++ b/apps/agent/agent/tools/record_fact.ts @@ -64,7 +64,7 @@ export default defineTool({ band: result.band, score: Number(result.score.toFixed(2)), rationale: result.rationale, - ...(result.reason ? { reason: result.reason } : {}), + reason: result.reason || undefined, }; }, }); diff --git a/apps/agent/agent/tools/research_company.ts b/apps/agent/agent/tools/research_company.ts index 05b09a715..fd6ea10cb 100644 --- a/apps/agent/agent/tools/research_company.ts +++ b/apps/agent/agent/tools/research_company.ts @@ -1,10 +1,10 @@ import { ActivityType, db } from "@crm/db"; import { defineTool } from "eve/tools"; import { z } from "zod"; -import { extract } from "../lib/context-dev"; +import { extract, type JsonSchema } from "../lib/context-dev"; import { spend } from "../lib/focus"; -const RESEARCH_SCHEMA = { +const RESEARCH_SCHEMA: JsonSchema = { type: "object", properties: { positioning: { @@ -31,13 +31,43 @@ const RESEARCH_SCHEMA = { }, }, required: ["positioning"], -} as const; +}; const RESEARCH_INSTRUCTIONS = "Read this company's marketing site and answer as a salesperson preparing " + "for a first call. Be specific and factual; leave a field empty rather than " + "guessing."; +const briefText = z.string().trim().min(1).nullable().catch(null); + +const briefList = z + .array(z.string().nullable().catch(null)) + .transform((items) => items.filter((item) => item !== null)) + .catch([]); + +const briefScalar = z + .union([z.string(), z.number(), z.boolean()]) + .nullable() + .catch(null); + +const researchBrief = z + .object({ + positioning: briefText, + pricingModel: briefText, + targetCustomer: briefText, + notableCustomers: briefList, + recentNews: briefList, + }) + .catch({ + positioning: null, + pricingModel: null, + targetCustomer: null, + notableCustomers: [], + recentNews: [], + }); + +type ResearchBrief = z.infer; + export default defineTool({ description: "Read a company's marketing site and write a research brief to its timeline: positioning, pricing, who they sell to, notable customers, recent news.", @@ -72,11 +102,7 @@ export default defineTool({ const charge = spend(2); if (!charge.ok) return { written: false as const, reason: charge.reason }; - const result = await extract( - url, - RESEARCH_SCHEMA as unknown as Record, - RESEARCH_INSTRUCTIONS, - ); + const result = await extract(url, RESEARCH_SCHEMA, RESEARCH_INSTRUCTIONS); if (result.outcome === "failed") { return { written: false as const, reason: result.reason }; @@ -90,11 +116,15 @@ export default defineTool({ if (!author) return { written: false as const, reason: "No user to attribute to." }; + const scalar = briefScalar.parse(result.data); const activity = await db.activity.create({ data: { type: ActivityType.ENRICHMENT, subject: `Research brief — ${company.name}`, - body: formatBrief(result.data), + body: + scalar === null + ? formatBrief(researchBrief.parse(result.data)) + : String(scalar), occurredAt: new Date(), companyId: company.id, createdById: author, @@ -117,40 +147,21 @@ export default defineTool({ }, }); -function formatBrief(data: unknown): string { - if (typeof data !== "object" || data === null) return String(data ?? ""); - - const brief = data as { - positioning?: unknown; - pricingModel?: unknown; - targetCustomer?: unknown; - notableCustomers?: unknown; - recentNews?: unknown; - }; - +function formatBrief(brief: ResearchBrief): string { const lines: string[] = []; - const text = (value: unknown) => - typeof value === "string" && value.trim() ? value.trim() : null; - const list = (value: unknown) => - Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; - const positioning = text(brief.positioning); - if (positioning) lines.push(positioning); + if (brief.positioning) lines.push(brief.positioning); + if (brief.pricingModel) lines.push(`Pricing: ${brief.pricingModel}`); + if (brief.targetCustomer) lines.push(`Sells to: ${brief.targetCustomer}`); - const pricing = text(brief.pricingModel); - if (pricing) lines.push(`Pricing: ${pricing}`); - - const target = text(brief.targetCustomer); - if (target) lines.push(`Sells to: ${target}`); - - const customers = list(brief.notableCustomers); - if (customers.length > 0) lines.push(`Customers: ${customers.join(", ")}`); + if (brief.notableCustomers.length > 0) { + lines.push(`Customers: ${brief.notableCustomers.join(", ")}`); + } - const news = list(brief.recentNews); - if (news.length > 0) { - lines.push(`Recently:\n${news.map((item) => `• ${item}`).join("\n")}`); + if (brief.recentNews.length > 0) { + lines.push( + `Recently:\n${brief.recentNews.map((item) => `• ${item}`).join("\n")}`, + ); } return lines.join("\n\n"); diff --git a/apps/agent/evals/agent-builder.eval.ts b/apps/agent/evals/agent-builder.eval.ts index f32c620e8..6612ee907 100644 --- a/apps/agent/evals/agent-builder.eval.ts +++ b/apps/agent/evals/agent-builder.eval.ts @@ -1,4 +1,5 @@ import { db } from "@crm/db"; +import { agentManifest } from "@crm/validation/agent-manifest"; import { defineEval } from "eve/evals"; import { equals, satisfies } from "eve/evals/expect"; @@ -62,8 +63,8 @@ export default defineEval({ sessionId = await waitForBuilderSession(conversation.id, t.signal); await t.require( sessionId, - satisfies( - (value) => typeof value === "string", + satisfies( + (value) => value !== null, "builder session started", ), ); @@ -98,15 +99,12 @@ export default defineEval({ 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) - : []; + const parsed = agentManifest.safeParse(value); return ( - scope.mode === "WORKSPACE" && - actions.length === 1 && - actions[0]?.type === "run.summary" + parsed.success && + parsed.data.dataScope.mode === "WORKSPACE" && + parsed.data.actions.length === 1 && + parsed.data.actions[0]?.type === "run.summary" ); }, "saved manifest is workspace-scoped and side-effect-free"), ); @@ -166,9 +164,3 @@ async function cleanupBuilderEval( } 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/scripts/backfill-brand-images.ts b/apps/agent/scripts/backfill-brand-images.ts index 7b8d31680..8f5a9a3c7 100644 --- a/apps/agent/scripts/backfill-brand-images.ts +++ b/apps/agent/scripts/backfill-brand-images.ts @@ -1,8 +1,15 @@ import { db } from "@crm/db"; import { blobEnabled, isMirrored, mirror } from "@crm/db/blob"; -import { COMPANY_IMAGE_FIELDS } from "@crm/db/images"; +import { COMPANY_IMAGE_FIELDS, type CompanyImageField } from "@crm/db/images"; +import { z } from "zod"; import { brandToUpdate } from "../agent/lib/brand-mapping"; +type CompanyImagePatch = Partial< + Record +>; + +const storedText = z.string().nullable().catch(null); + if (!blobEnabled()) { console.warn( "No BLOB_READ_WRITE_TOKEN — icon tone and dark artwork will still be " + @@ -54,14 +61,12 @@ for (const row of rows) { nameIsPlaceholder: false, }); - const patch: Record = { - ...(typeof update.iconDarkUrl === "string" - ? { iconDarkUrl: update.iconDarkUrl } - : {}), - ...(typeof update.iconTone === "string" - ? { iconTone: update.iconTone } - : {}), - }; + const patch: CompanyImagePatch = {}; + const iconDarkUrl = storedText.parse(update.iconDarkUrl); + const iconTone = storedText.parse(update.iconTone); + + if (iconDarkUrl !== null) patch.iconDarkUrl = iconDarkUrl; + if (iconTone !== null) patch.iconTone = iconTone; for (const slot of COMPANY_IMAGE_FIELDS) { const current = row.company[slot]; diff --git a/apps/agent/test/channel-auth.spec.ts b/apps/agent/test/channel-auth.spec.ts index 0108b5d8e..06fa37ae4 100644 --- a/apps/agent/test/channel-auth.spec.ts +++ b/apps/agent/test/channel-auth.spec.ts @@ -9,11 +9,21 @@ import { isAutomated } from "../agent/lib/approval"; const SECRET = "test-secret-at-least-long-enough-to-be-a-secret"; const auth = repFromCrm(SECRET); -async function mint( - claims: Record, - secret = SECRET, -): Promise { - const encode = (value: object) => +type BridgeHeader = { alg: string; typ: string }; + +type BridgeClaims = { + iss: string; + aud: string; + sub: string | undefined; + email: string; + name: string; + iat: number; + nbf: number; + exp: number; +}; + +async function mint(claims: BridgeClaims, secret = SECRET): Promise { + const encode = (value: BridgeClaims | BridgeHeader) => Buffer.from(JSON.stringify(value)).toString("base64url"); const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`; @@ -40,7 +50,7 @@ function request(token: string | null): Request { }); } -function claims(overrides: Record = {}) { +function claims(overrides: Partial = {}): BridgeClaims { const now = Math.floor(Date.now() / 1000); return { iss: BRIDGE_ISSUER, diff --git a/apps/agent/test/durable-agent-runtime.integration.spec.ts b/apps/agent/test/durable-agent-runtime.integration.spec.ts index 669f10f7e..27f74db78 100644 --- a/apps/agent/test/durable-agent-runtime.integration.spec.ts +++ b/apps/agent/test/durable-agent-runtime.integration.spec.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; import type { SendFn } from "eve/channels"; +import { z } from "zod"; import audit from "../agent/hooks/audit"; import { builderToken, @@ -15,9 +16,12 @@ import { import { createRunActivity, finishRun, + runResultOf, stageRunResult, } from "../agent/lib/run-runtime"; +const attachmentBytes = z.object({ data: z.instanceof(Uint8Array) }); + const suffix = crypto.randomUUID(); const userId = `durable-runtime-user-${suffix}`; const domain = `durable-${suffix}.example.test`; @@ -503,8 +507,8 @@ describe("durable custom-agent runtime", () => { select: { id: true, submissions: { select: { id: true } } }, }); builderConversationIds.push(conversation.id); - let delivered: unknown; - const send = (async (input: unknown) => { + let delivered: Parameters[0] | null = null; + const send = (async (input: Parameters[0]) => { delivered = input; return { id: `durable-session-${suffix}-attachment` }; }) as unknown as SendFn; @@ -520,7 +524,7 @@ describe("durable custom-agent runtime", () => { mediaType: "text/plain", filename: "brief.txt", }); - expect(Buffer.from(recordOf(parts[1]).data as Uint8Array)).toEqual(content); + expect(Buffer.from(attachmentBytes.parse(parts[1]).data)).toEqual(content); }); it("ingests a replayed Eve event and its usage exactly once", async () => { @@ -536,7 +540,7 @@ describe("durable custom-agent runtime", () => { session: { id: string; auth: { - current: { attributes: Record }; + current: { attributes: Record }; initiator: null; }; }; @@ -590,7 +594,7 @@ describe("durable custom-agent runtime", () => { id: string; parent?: unknown; auth: { - current: { attributes: Record }; + current: { attributes: Record }; initiator: null; }; }; @@ -689,7 +693,7 @@ describe("durable custom-agent runtime", () => { await satisfyRequiredActivity(run.id, "staged-success"); await finishRun(run.id, { summary: staged.summary ?? "Staged safely", - result: staged.result as Record, + result: runResultOf(staged.result), }); expect( await db.agentRun.findUniqueOrThrow({ where: { id: run.id } }), @@ -766,7 +770,7 @@ describe("durable custom-agent runtime", () => { const finished = await finishRun(run.id, { summary: staged.summary ?? "The deal was already closed", - result: staged.result as Record, + result: runResultOf(staged.result), }); expect(finished).toEqual({ id: run.id, status: "SUCCEEDED" }); expect(await db.agentAction.count({ where: { runId: run.id } })).toBe(0); @@ -848,9 +852,3 @@ describe("durable custom-agent runtime", () => { ).toBe(0); }); }); - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} diff --git a/apps/agent/test/e2e/dispatch.e2e.ts b/apps/agent/test/e2e/dispatch.e2e.ts index 10e0db9e7..6ae373760 100644 --- a/apps/agent/test/e2e/dispatch.e2e.ts +++ b/apps/agent/test/e2e/dispatch.e2e.ts @@ -205,7 +205,7 @@ async function main() { } finally { const failure = await cleanUp(agentId, companyId, dealId, taskId).then( () => null, - (error: unknown) => reasonOf(error), + (cause: unknown) => reasonOf(cause), ); record( "cleanup leaves no seeded rows", diff --git a/apps/agent/test/e2e/e2e-agents.ts b/apps/agent/test/e2e/e2e-agents.ts index 5bb131ff2..c70427d34 100644 --- a/apps/agent/test/e2e/e2e-agents.ts +++ b/apps/agent/test/e2e/e2e-agents.ts @@ -49,6 +49,6 @@ export async function removeEventRuns( return runIds.length; } -export function reasonOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); +export function reasonOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); } diff --git a/apps/agent/test/e2e/slack-join.e2e.ts b/apps/agent/test/e2e/slack-join.e2e.ts index 5874daf2a..be17cc4ef 100644 --- a/apps/agent/test/e2e/slack-join.e2e.ts +++ b/apps/agent/test/e2e/slack-join.e2e.ts @@ -97,8 +97,8 @@ async function main() { type: "slack.channel.join", channelId: E2E.slackJoin.bogusChannelId, channelName: E2E.slackJoin.bogusChannelName, - }).catch((error: unknown) => - error instanceof Error ? error.message : String(error), + }).catch((cause: unknown) => + cause instanceof Error ? cause.message : String(cause), ); record( "a channel that does not exist is refused", diff --git a/apps/agent/test/slack-people.integration.spec.ts b/apps/agent/test/slack-people.integration.spec.ts index ce618b6a4..91fc768d7 100644 --- a/apps/agent/test/slack-people.integration.spec.ts +++ b/apps/agent/test/slack-people.integration.spec.ts @@ -62,7 +62,21 @@ afterEach(async () => { } }); -function slackReply(body: unknown): Response { +type SlackChannelReply = { + id: string; + name: string; + is_member: boolean; + unknown?: number; +}; + +type SlackListReply = { + ok: boolean; + error?: string; + channels?: SlackChannelReply[]; + response_metadata?: { next_cursor: string }; +}; + +function slackReply(body: SlackListReply): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, @@ -187,7 +201,10 @@ describe("persistSlackChannels", () => { describe("refreshSlackChannels", () => { it("aborts a stalled Slack list request", async () => { let signal: AbortSignal | null = null; - globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + globalThis.fetch = (async ( + _input: RequestInfo | URL, + init?: RequestInit, + ) => { signal = init?.signal ?? null; return slackReply({ ok: true, channels: [] }); }) as typeof fetch; From cf905682c6ad916eb6303563bcaed58dd0d1188f Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:00:23 -0700 Subject: [PATCH 6/7] CMP-82 refactor: infer app component types from the router instead of casting The components hand-wrote types that duplicated the server's return shape and cast tRPC output into them. The capabilities type had already drifted - it declared dataScope nullable where the success branch always returns it. They are inferred from RouterOutputs now, which deleted the casts with them. Naming a Json column through inferRouterOutputs is a hard TS2589: Serialize maps over Prisma's recursive JsonValue and blows the instantiation limit. That, not drift, is what the double casts were working around. Reading the whole row into a parse is fine, so every Json column here is parsed at the boundary instead. No markup, className or rendered output changed. --- .../(agent-builder)/agents/[agentId]/page.tsx | 10 +- .../(agent-builder)/chat/[chatId]/page.tsx | 12 +- .../[slug]/(agent-builder)/missing-record.ts | 14 +- apps/app/app/(app)/[slug]/layout.tsx | 19 +- apps/app/app/(app)/[slug]/overview-scope.tsx | 4 +- apps/app/app/(app)/[slug]/sales-dashboard.tsx | 7 +- .../connections/google-connection.tsx | 10 +- .../connections/microsoft-connection.tsx | 10 +- .../slack/slack-connect-button.tsx | 27 ++- .../(landing)/grant-access/grant-access.tsx | 9 +- apps/app/app/(landing)/grant-access/page.tsx | 6 +- apps/app/app/(landing)/sign-in/page.tsx | 16 +- .../app/(landing)/sign-in/social-sign-in.tsx | 8 +- apps/app/app/t/[site]/route.ts | 14 +- .../agent-builder/agent-builder-chat.tsx | 166 +++++++++++------- .../agent-builder/agent-builder-sidebar.tsx | 12 +- .../agent-builder/agent-capabilities.tsx | 69 +++----- .../agent-builder/agent-composer.tsx | 4 +- .../agent-builder/agent-history.tsx | 77 ++++---- .../components/agent-builder/agent-result.tsx | 32 ++-- .../agent-builder/team-agent-detail.tsx | 5 +- apps/app/components/crm/agent-panel.tsx | 16 +- .../components/crm/fields/field-editor.tsx | 8 +- apps/app/components/crm/fields/fields-copy.ts | 12 +- .../components/crm/fields/fields-entity.ts | 8 +- .../components/crm/fields/standard-fields.ts | 4 +- .../crm/record-sheet/record-actions.tsx | 4 +- .../crm/record-sheet/record-stack.ts | 4 +- .../crm/timeline/activity-composer.tsx | 4 +- .../crm/timeline/timeline-entry.tsx | 16 +- apps/app/components/crm/timeline/timeline.tsx | 15 +- apps/app/components/inline-script.tsx | 2 +- apps/app/components/landing/analytics.tsx | 2 +- apps/app/test/agent-transcript.spec.ts | 14 +- apps/app/test/onboarding-gate.spec.ts | 8 +- apps/app/test/tracking-bundle.spec.ts | 2 +- 36 files changed, 363 insertions(+), 287 deletions(-) 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 index baaf9e59d..1f5aa61c1 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx +++ b/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx @@ -31,13 +31,9 @@ async function PrefetchedTeamAgent({ const client = getServerTrpcClient(); const [agent, runs, activity] = await Promise.all([ - client.agents.byId.query({ id: agentId }).catch(nullIfMissing), - client.agents.history - .query({ id: agentId, limit: 50 }) - .catch(nullIfMissing), - client.agents.activity - .query({ id: agentId, limit: 100 }) - .catch(nullIfMissing), + nullIfMissing(client.agents.byId.query({ id: agentId })), + nullIfMissing(client.agents.history.query({ id: agentId, limit: 50 })), + nullIfMissing(client.agents.activity.query({ id: agentId, limit: 100 })), ]); if (!agent || !runs || !activity) notFound(); 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 index 68014cd94..21e32bed4 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx +++ b/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx @@ -30,16 +30,16 @@ async function PrefetchedAgentChat({ const client = getServerTrpcClient(); if (isSharedChatToken(chatId)) { - const shared = await client.conversations.shared - .query({ token: chatId }) - .catch(nullIfMissing); + const shared = await nullIfMissing( + client.conversations.shared.query({ token: chatId }), + ); return ; } - const conversation = await client.conversations.builderById - .query({ id: chatId }) - .catch(nullIfMissing); + const conversation = await nullIfMissing( + client.conversations.builderById.query({ id: chatId }), + ); if (!conversation) notFound(); diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts b/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts index 66d6f58a2..e14a5a163 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts +++ b/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts @@ -1,9 +1,13 @@ import { TRPCClientError } from "@trpc/client"; -export function nullIfMissing(error: unknown): null { - if (error instanceof TRPCClientError && error.data?.code === "NOT_FOUND") { - return null; - } +export async function nullIfMissing(query: Promise): Promise { + try { + return await query; + } catch (error) { + if (error instanceof TRPCClientError && error.data?.code === "NOT_FOUND") { + return null; + } - throw error; + throw error; + } } diff --git a/apps/app/app/(app)/[slug]/layout.tsx b/apps/app/app/(app)/[slug]/layout.tsx index b993c2113..863d0da85 100644 --- a/apps/app/app/(app)/[slug]/layout.tsx +++ b/apps/app/app/(app)/[slug]/layout.tsx @@ -40,20 +40,25 @@ export default function AppLayout({ ); } +async function loadWorkspace() { + try { + return await getServerQueryClient().fetchQuery( + getServerTrpc().workspace.get.queryOptions(), + ); + } catch (error) { + unstable_rethrow(error); + return null; + } +} + async function WorkspaceHeader({ params, }: Pick, "params">) { await connection(); - const workspacePromise = getServerQueryClient() - .fetchQuery(getServerTrpc().workspace.get.queryOptions()) - .catch((error: unknown) => { - unstable_rethrow(error); - return null; - }); const [{ user }, { slug }, workspace] = await Promise.all([ requireMailboxAccess(), params, - workspacePromise, + loadWorkspace(), ]); if (workspace && workspace.slug !== slug) notFound(); diff --git a/apps/app/app/(app)/[slug]/overview-scope.tsx b/apps/app/app/(app)/[slug]/overview-scope.tsx index 8a0ceb9d9..dcc0632c2 100644 --- a/apps/app/app/(app)/[slug]/overview-scope.tsx +++ b/apps/app/app/(app)/[slug]/overview-scope.tsx @@ -8,10 +8,10 @@ import { overviewParsers, } from "./overview-search-params"; -const LABELS: Record = { +const LABELS = { me: "Me", everyone: "Everyone", -}; +} satisfies Record; function isScope(value: string): value is OverviewScope { return (OVERVIEW_SCOPES as readonly string[]).includes(value); diff --git a/apps/app/app/(app)/[slug]/sales-dashboard.tsx b/apps/app/app/(app)/[slug]/sales-dashboard.tsx index cba5acee1..d4a3162d8 100644 --- a/apps/app/app/(app)/[slug]/sales-dashboard.tsx +++ b/apps/app/app/(app)/[slug]/sales-dashboard.tsx @@ -58,11 +58,8 @@ export function SalesDashboard({ summary }: { summary: Summary }) { } = summary; const money = (cents: number) => formatMoneyCompact(cents, reportingCurrency); - const exact = (value: unknown) => - formatMoney( - typeof value === "number" ? value : Number(value), - reportingCurrency, - ); + const exact = (value: number | string) => + formatMoney(Number(value), reportingCurrency); const hasTrend = trend.some((point) => point.won > 0 || point.created > 0); diff --git a/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx index bc9adb386..20f3fca8b 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx @@ -123,10 +123,12 @@ function GoogleUnavailable() { ); } -const CONNECT_ERRORS: Record = { - "email_doesn't_match": +const CONNECT_ERRORS = new Map([ + [ + "email_doesn't_match", "That Google account has a different email address to the one you sign in with, so it cannot be attached to your account. Connect the Google account that matches your sign-in address.", -}; + ], +]); function ConnectGoogle({ slug, @@ -196,7 +198,7 @@ function ConnectGoogle({ Google did not finish connecting - {CONNECT_ERRORS[connectError] ?? + {CONNECT_ERRORS.get(connectError) ?? "Google returned an error before the connection was made. Try again."} diff --git a/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx index 9418fe806..6be4afa5e 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx @@ -42,10 +42,12 @@ import { useTRPC } from "@/lib/trpc/client"; const AUTO_CREATE = "Add the company and contact when you reply to someone new"; -const CONNECT_ERRORS: Record = { - "email_doesn't_match": +const CONNECT_ERRORS = new Map([ + [ + "email_doesn't_match", "That Microsoft account has a different email address to the one you sign in with, so it cannot be attached to your account. Connect the Microsoft account that matches your sign-in address.", -}; + ], +]); function MicrosoftUnavailable() { return ( @@ -134,7 +136,7 @@ function ConnectMicrosoft({ Microsoft did not finish connecting - {CONNECT_ERRORS[connectError] ?? + {CONNECT_ERRORS.get(connectError) ?? "Microsoft returned an error before the connection was made. Try again."} diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx index 919e79589..74b7fc389 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx @@ -5,17 +5,28 @@ import { Button } from "@crm/ui/components/button"; import { useState } from "react"; import { toast } from "sonner"; -const CONNECT_ERRORS: Record = { - access_denied: "Slack installation was cancelled before access was granted.", - account_already_linked_to_different_user: +const CONNECT_ERRORS = new Map([ + [ + "access_denied", + "Slack installation was cancelled before access was granted.", + ], + [ + "account_already_linked_to_different_user", "That Slack installer is already linked to another CRM account.", - "email_doesn't_match": + ], + [ + "email_doesn't_match", "The Slack installer's email must match the CRM account you are signed in with.", - oauth_code_verification_failed: + ], + [ + "oauth_code_verification_failed", "Slack rejected the app credentials or redirect URL. Check the client ID, client secret, and OAuth redirect URL, then try again.", - user_info_is_missing: + ], + [ + "user_info_is_missing", "Slack did not return the installer's profile. Confirm the app has users:read and users:read.email, reinstall it, then try again.", -}; + ], +]); async function startSlackOAuth(slug: string) { try { @@ -77,7 +88,7 @@ export function SlackConnectButton({ {connectError ? (

- {CONNECT_ERRORS[connectError] ?? + {CONNECT_ERRORS.get(connectError) ?? `Slack could not be connected (${connectError.replaceAll("_", " ")}).`}

) : null} diff --git a/apps/app/app/(landing)/grant-access/grant-access.tsx b/apps/app/app/(landing)/grant-access/grant-access.tsx index acf729813..9f5ee1fad 100644 --- a/apps/app/app/(landing)/grant-access/grant-access.tsx +++ b/apps/app/app/(landing)/grant-access/grant-access.tsx @@ -10,10 +10,17 @@ import GoogleLogo from "@crm/ui/components/brand-logos/google"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; import { Button } from "@crm/ui/components/button"; import { Spinner } from "@crm/ui/components/spinner"; +import type { FC, SVGProps } from "react"; import { useState } from "react"; import { toast } from "sonner"; import { signOutAndRedirect } from "@/lib/sign-out"; +type ProviderGrant = { + label: string; + scopes: readonly string[]; + Logo: FC>; +}; + const PROVIDERS = { google: { label: "Grant Google access", @@ -25,7 +32,7 @@ const PROVIDERS = { scopes: [...MICROSOFT_SYNC_SCOPES], Logo: MicrosoftLogo, }, -} as const satisfies Record; +} as const satisfies Record; export function GrantAccess({ providers, diff --git a/apps/app/app/(landing)/grant-access/page.tsx b/apps/app/app/(landing)/grant-access/page.tsx index f5ee181d9..39d306de4 100644 --- a/apps/app/app/(landing)/grant-access/page.tsx +++ b/apps/app/app/(landing)/grant-access/page.tsx @@ -1,4 +1,4 @@ -import { mailboxGrantsNeeded } from "@crm/auth"; +import { type MailboxProviderId, mailboxGrantsNeeded } from "@crm/auth"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { AuthHeading, AuthShell } from "@/components/auth-shell"; @@ -11,12 +11,12 @@ export const metadata: Metadata = { export const instant = false; -const DESCRIPTION: Record = { +const DESCRIPTION = { google: "This CRM reads your Gmail and Calendar so meetings and email threads show up on the right company. It is read-only — nothing is ever sent on your behalf.", microsoft: "This CRM reads your Outlook mail so email threads show up on the right company. It is read-only — nothing is ever sent on your behalf.", -}; +} satisfies Record; const BOTH = "This CRM reads your mail and calendar so meetings and email threads show up on the right company. It is read-only — nothing is ever sent on your behalf."; diff --git a/apps/app/app/(landing)/sign-in/page.tsx b/apps/app/app/(landing)/sign-in/page.tsx index 0a7929fc1..5b0e451af 100644 --- a/apps/app/app/(landing)/sign-in/page.tsx +++ b/apps/app/app/(landing)/sign-in/page.tsx @@ -30,6 +30,16 @@ async function signInOptions(): Promise { } } +async function currentSession() { + try { + return await getSession(); + } catch (error) { + unstable_rethrow(error); + console.error("Sign-in: could not read the session.", error); + return null; + } +} + export default function SignInPage({ searchParams }: PageProps<"/sign-in">) { return ( @@ -51,11 +61,7 @@ async function SignIn({ searchParams, }: Pick, "searchParams">) { const [session, options, { method }] = await Promise.all([ - getSession().catch((error: unknown) => { - unstable_rethrow(error); - console.error("Sign-in: could not read the session.", error); - return null; - }), + currentSession(), signInOptions(), searchParams, ]); diff --git a/apps/app/app/(landing)/sign-in/social-sign-in.tsx b/apps/app/app/(landing)/sign-in/social-sign-in.tsx index 021d2249d..882f89c12 100644 --- a/apps/app/app/(landing)/sign-in/social-sign-in.tsx +++ b/apps/app/app/(landing)/sign-in/social-sign-in.tsx @@ -6,13 +6,19 @@ import GoogleLogo from "@crm/ui/components/brand-logos/google"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; import { Button } from "@crm/ui/components/button"; import { Spinner } from "@crm/ui/components/spinner"; +import type { FC, SVGProps } from "react"; import { useState } from "react"; import { toast } from "sonner"; +type ProviderChoice = { + label: string; + Logo: FC>; +}; + const PROVIDERS = { google: { label: "Continue with Google", Logo: GoogleLogo }, microsoft: { label: "Continue with Microsoft", Logo: MicrosoftLogo }, -} as const satisfies Record; +} as const satisfies Record; export function SocialSignIn({ provider }: { provider: MailboxProviderId }) { const [pending, setPending] = useState(false); diff --git a/apps/app/app/t/[site]/route.ts b/apps/app/app/t/[site]/route.ts index 082350e72..98e5e16f5 100644 --- a/apps/app/app/t/[site]/route.ts +++ b/apps/app/app/t/[site]/route.ts @@ -35,14 +35,14 @@ export async function GET( `${origin}/api/t/e`, ); - return new Response(source, { - headers: { - "content-type": "application/javascript; charset=utf-8", - "cache-control": `public, max-age=${CONFIG_MAX_AGE_SECONDS}, s-maxage=${CONFIG_MAX_AGE_SECONDS}`, - "x-content-type-options": "nosniff", - ...(payload.hash ? { etag: `"${payload.hash}"` } : {}), - }, + const headers = new Headers({ + "content-type": "application/javascript; charset=utf-8", + "cache-control": `public, max-age=${CONFIG_MAX_AGE_SECONDS}, s-maxage=${CONFIG_MAX_AGE_SECONDS}`, + "x-content-type-options": "nosniff", }); + if (payload.hash) headers.set("etag", `"${payload.hash}"`); + + return new Response(source, { headers }); } function empty(): Response { diff --git a/apps/app/components/agent-builder/agent-builder-chat.tsx b/apps/app/components/agent-builder/agent-builder-chat.tsx index 6c5897a1c..b2e3626ea 100644 --- a/apps/app/components/agent-builder/agent-builder-chat.tsx +++ b/apps/app/components/agent-builder/agent-builder-chat.tsx @@ -42,6 +42,7 @@ import type { EveMessage, EveMessageInputRequest } from "eve/react"; import Link from "next/link"; import { Fragment, type ReactNode, useState } from "react"; import { toast } from "sonner"; +import { z } from "zod"; import { AgentClarificationComposer, type ClarificationResponse, @@ -101,21 +102,87 @@ const BUILDER_STEP_ARTIFACTS = [ "agent/manifest.json", "agent/README.md", ] as const; -type BuilderSubmission = { - id: string; - createdAt: string; - clientRequestId?: string | null; - commandType: "CHAT" | "CREATE_AGENT"; - message: unknown; - status: string; - errorMessage: string | null; -}; +const submissionResource = z.object({ + kind: z.enum(["integration", "company", "contact", "deal"]), + id: z.string().min(1), + label: z.string().min(1), + detail: z.string().nullable().optional().catch(null), + imageUrl: z.string().nullable().optional().catch(null), +}); + +const storedAttachment = z.object({ + id: z.string().min(1), + name: z.string().min(1), + type: z.string().min(1), + size: z.number(), + previewUrl: z.string().nullable().optional().catch(null), +}); + +const uploadAttachment = z.object({ + name: z.string().min(1), + type: z.string().min(1), + size: z.number(), + contentBase64: z.string().min(1), + previewUrl: z.string().nullable().optional().catch(null), +}); + +const builderMessage = z + .object({ + text: z.string().nullable().catch(null), + resources: z.array(submissionResource).catch([]), + attachments: z + .array(z.union([storedAttachment, uploadAttachment])) + .catch([]), + inputResponse: z + .object({ requestId: z.string().min(1) }) + .nullable() + .catch(null), + }) + .catch({ + text: null, + resources: [], + attachments: [], + inputResponse: null, + }); + +type BuilderMessage = z.infer; + +const builderSubmissions = z.array( + z.object({ + id: z.string(), + createdAt: z.string(), + clientRequestId: z.string().nullable().catch(null), + commandType: z.enum(["CHAT", "CREATE_AGENT"]), + message: builderMessage, + status: z.string(), + errorMessage: z.string().nullable().catch(null), + }), +); + +type BuilderSubmission = z.infer[number]; + +const streamEventShape = z.object({ + type: z.string().min(1), + meta: z.object({ id: z.string().min(1), at: z.string().min(1) }), +}); + +const streamEvents = z + .array( + z + .custom( + (value) => streamEventShape.safeParse(value).success, + ) + .nullable() + .catch(null), + ) + .catch([]) + .transform((events) => events.filter((event) => event !== null)); type PendingSubmission = { clientRequestId: string; createdAt: string; commandType: "CHAT" | "CREATE_AGENT"; - message: unknown; + message: BuilderMessage; }; export function AgentBuilderChat({ @@ -227,7 +294,7 @@ export function AgentBuilderChat({ } const data = conversation.data ?? (initialData as Conversation); - const submissions = data.submissions as BuilderSubmission[]; + const submissions = builderSubmissions.parse(data.submissions); const confirmedRequestIds = new Set( submissions .map((submission) => submission.clientRequestId) @@ -236,8 +303,7 @@ export function AgentBuilderChat({ const pendingSubmissions = sending.filter( (item) => !confirmedRequestIds.has(item.clientRequestId), ); - const persistedEvents = (events.data ?? - []) as unknown as MessageStreamEvent[]; + const persistedEvents = streamEvents.parse(events.data ?? []); const streamKey = builderSessionStreamKey( data.sessionId, submissions.at(-1)?.id ?? null, @@ -282,6 +348,7 @@ export function AgentBuilderChat({ text: prompt.message, resources: prompt.resources, attachments: prompt.attachments, + inputResponse: null, }, }, ]); @@ -382,6 +449,7 @@ export function AgentBuilderChat({ submission={{ id: item.clientRequestId, createdAt: item.createdAt, + clientRequestId: item.clientRequestId, commandType: item.commandType, message: item.message, status: "PENDING", @@ -534,13 +602,15 @@ function BuilderEventFollower({ } }; - void follow() - .catch((error: unknown) => { + void (async () => { + try { + await follow(); + } catch (error) { if (!controller.signal.aborted) console.error(error); - }) - .finally(() => { + } finally { if (!controller.signal.aborted) onEnded(); - }); + } + })(); return () => controller.abort(); }); @@ -596,9 +666,8 @@ function SharedAgentChat({ }: { conversation: SharedConversation; }) { - const submissions = - conversation.submissions as unknown as BuilderSubmission[]; - const events = conversation.events as unknown as MessageStreamEvent[]; + const submissions = builderSubmissions.parse(conversation.submissions); + const events = streamEvents.parse(conversation.events); const messages = messagesFromEvents(events); const timeline = conversationTimeline(submissions, events, messages); const answeredQuestionIds = questionResponseIds(submissions); @@ -740,13 +809,14 @@ function UserSubmission({ error: string | null; sending?: boolean; }) { - const message = builderMessageOf(submission.message); + const message = submission.message; + const messageText = message.text ?? "Message unavailable"; const command = submission.commandType === "CREATE_AGENT" - ? consumeBuilderCommand(message.text) + ? consumeBuilderCommand(messageText) : null; - const text = command?.body ?? message.text; - const response = inputResponseOf(submission.message); + const text = command?.body ?? messageText; + const response = message.inputResponse; return (
0) return null; - if (typeof row.text !== "string" || !row.text.trim()) return null; + const { message } = submission; + if (message.inputResponse) return null; + if (!message.text?.trim()) return null; return { commandType: submission.commandType, - message: row.text, - resources: Array.isArray(row.resources) - ? (row.resources as BuilderPrompt["resources"]) - : [], - attachments: Array.isArray(row.attachments) - ? (row.attachments as BuilderPrompt["attachments"]) - : [], + message: message.text, + resources: message.resources, + attachments: message.attachments, }; } @@ -1535,12 +1581,6 @@ function compactSummary(value: string | undefined, fallback: string): string { return textOf(value, fallback).replace(/\s+\([^()]+\)\s*\.?$/, ""); } -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function textOf(value: string | undefined, fallback: string): string { return value?.trim() ? value : fallback; } diff --git a/apps/app/components/agent-builder/agent-builder-sidebar.tsx b/apps/app/components/agent-builder/agent-builder-sidebar.tsx index b8502b4d1..7b58eba03 100644 --- a/apps/app/components/agent-builder/agent-builder-sidebar.tsx +++ b/apps/app/components/agent-builder/agent-builder-sidebar.tsx @@ -234,18 +234,16 @@ function groupConversations(conversations: Conversation[], now: number) { if (!now) return []; const labels: ChatDateGroup[] = ["Today", "Yesterday", "Last 7 days"]; - const items: Record = { - Today: [], - Yesterday: [], - "Last 7 days": [], - }; + const items = new Map( + labels.map((label) => [label, []]), + ); for (const conversation of conversations) { const label = chatDateGroup(conversation.lastMessageAt, now); - if (label) items[label].push(conversation); + if (label) items.get(label)?.push(conversation); } return labels - .map((label) => ({ label, items: items[label] })) + .map((label) => ({ label, items: items.get(label) ?? [] })) .filter((group) => group.items.length > 0); } diff --git a/apps/app/components/agent-builder/agent-capabilities.tsx b/apps/app/components/agent-builder/agent-capabilities.tsx index 9fe8fa03b..1f016172b 100644 --- a/apps/app/components/agent-builder/agent-capabilities.tsx +++ b/apps/app/components/agent-builder/agent-capabilities.tsx @@ -22,27 +22,21 @@ import { } from "@/components/slack/channel-picker"; import { useSlackChannels } from "@/components/slack/use-slack-channels"; import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; import { CreateChannelDialog } from "./create-channel-dialog"; -export type Resource = { id: string; kind: string; label: string }; +export type Capabilities = RouterOutputs["agents"]["byId"]["capabilities"]; -export type Capabilities = { - readable: boolean; - problem: string | null; - channel: { kind: "channel" | "user"; id: string; label: string } | null; - actions: Array<{ type: string; provider: string; summary: string }>; - dataScope: { - mode: "SELECTED" | "WORKSPACE"; - summary: string; - resources: Resource[]; - } | null; -}; +export type Resource = Extract< + Capabilities, + { readable: true } +>["dataScope"]["resources"][number]; -const ACTION_LABELS: Record = { - "slack.message.post": "Post a message", - "crm.activity.create": "Write a note or task on the record", - "run.summary": "Write a summary of the run", -}; +const ACTION_LABELS = new Map([ + ["slack.message.post", "Post a message"], + ["crm.activity.create", "Write a note or task on the record"], + ["run.summary", "Write a summary of the run"], +]); export function AgentCapabilities({ agentId, @@ -136,29 +130,22 @@ export function AgentCapabilities({ revise.mutate({ id: agentId, clientRequestId: crypto.randomUUID(), - ...(channelChanged && picked - ? { channel: { id: picked.id, name: picked.name } } - : {}), - ...(actionsChanged - ? { - actions: capabilities.actions - .map((action) => action.type) - .filter((type) => !off.includes(type)), - } - : {}), - ...(scopeChanged - ? { - resources: shownResources.map((resource) => ({ - id: resource.id, - kind: resource.kind as - | "company" - | "contact" - | "deal" - | "integration", - label: resource.label, - })), - } - : {}), + channel: + channelChanged && picked + ? { id: picked.id, name: picked.name } + : undefined, + actions: actionsChanged + ? capabilities.actions + .map((action) => action.type) + .filter((type) => !off.includes(type)) + : undefined, + resources: scopeChanged + ? shownResources.map((resource) => ({ + id: resource.id, + kind: resource.kind, + label: resource.label, + })) + : undefined, }); }; @@ -207,7 +194,7 @@ export function AgentCapabilities({ >

- {ACTION_LABELS[action.type] ?? action.type} + {ACTION_LABELS.get(action.type) ?? action.type}

{action.summary || action.provider} diff --git a/apps/app/components/agent-builder/agent-composer.tsx b/apps/app/components/agent-builder/agent-composer.tsx index df1612926..4bed19abd 100644 --- a/apps/app/components/agent-builder/agent-composer.tsx +++ b/apps/app/components/agent-builder/agent-composer.tsx @@ -1457,12 +1457,12 @@ function ResourceResultsSkeleton() { ); } -const RESOURCE_ICONS: Record = { +const RESOURCE_ICONS = { integration: Application, company: Building, contact: User, deal: Partnership, -}; +} satisfies Record; function ResourceButton({ icon, diff --git a/apps/app/components/agent-builder/agent-history.tsx b/apps/app/components/agent-builder/agent-history.tsx index 089032f69..7df33a1ea 100644 --- a/apps/app/components/agent-builder/agent-history.tsx +++ b/apps/app/components/agent-builder/agent-history.tsx @@ -19,23 +19,33 @@ import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; import { cn } from "@crm/ui/lib/utils"; import { useState } from "react"; +import { z } from "zod"; import { runFailureReason } from "@/lib/agent-run-failure"; import type { RouterOutputs } from "@/lib/trpc/types"; type Runs = RouterOutputs["agents"]["history"]; type Activity = RouterOutputs["agents"]["activity"]; -type RunRow = Omit & { - events: Array<{ - id: string; - type: string; - data: unknown; - emittedAt: string; - }>; -}; -type ActivityRow = Omit & { - before: unknown; - after: unknown; -}; +type RunRow = Runs[number]; +type AuditRow = Activity[number]; + +const runEvents = z.array( + z.object({ + id: z.string(), + type: z.string(), + data: z.json(), + emittedAt: z.string(), + }), +); + +type RunEvent = z.infer[number]; + +const eventSummary = z + .object({ summary: z.string().refine((text) => text.trim().length > 0) }) + .transform((event) => event.summary) + .nullable() + .catch(null); + +const auditChange = z.object({ before: z.json(), after: z.json() }); const DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", @@ -180,9 +190,7 @@ export function AgentRuns({ ) : null}

- {expanded === run.id ? ( - - ) : null} + {expanded === run.id ? : null}
))} @@ -225,8 +233,9 @@ export function AgentRuns({ } function ExpandedRun({ run }: { run: RunRow }) { + const events = runEvents.parse(run.events); const timeline = [ - ...run.events.map((event) => ({ + ...events.map((event) => ({ kind: "event" as const, at: event.emittedAt, event, @@ -261,7 +270,7 @@ function ExpandedRun({ run }: { run: RunRow }) { {formatTime(entry.at)} - {eventLabel(entry.event.type, entry.event.data)} + {eventLabel(entry.event)} event @@ -294,8 +303,8 @@ function ExpandedRun({ run }: { run: RunRow }) { )} {run.eventsTruncated ? (
- Showing the first {run.events.length} of {run.totalEvents} steps. - This run is too long to display in full. + Showing the first {events.length} of {run.totalEvents} steps. This + run is too long to display in full.
) : null}
@@ -327,8 +336,7 @@ function RunMeta({ export function AgentActivity({ activity }: { activity: Activity }) { const [kind, setKind] = useState("ALL"); - const rows = activity as unknown as ActivityRow[]; - const visible = rows.filter( + const visible = activity.filter( (event) => kind === "ALL" || event.type.startsWith(kind), ); @@ -374,9 +382,9 @@ export function AgentActivity({ activity }: { activity: Activity }) { {event.summary} - {changeDetail(event.before, event.after) ? ( + {changeDetail(event) ? ( - {changeDetail(event.before, event.after)} + {changeDetail(event)} ) : null} @@ -400,16 +408,6 @@ export function AgentActivity({ activity }: { activity: Activity }) { ); } -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function textOf(value: unknown, fallback: string): string { - return typeof value === "string" && value.trim() ? value : fallback; -} - function humanStatus(value: string): string { return value .toLowerCase() @@ -434,19 +432,22 @@ function duration(startedAt: string | null, finishedAt: string | null): string { return `${Math.max(0, milliseconds / 1000).toFixed(1)}s`; } -function eventLabel(type: string, data: unknown): string { - const payload = recordOf(data); - return textOf(payload.summary, humanStatus(type.replace(/\./g, " "))); +function eventLabel(event: RunEvent): string { + return ( + eventSummary.parse(event.data) ?? + humanStatus(event.type.replace(/\./g, " ")) + ); } -function changeDetail(before: unknown, after: unknown): string | null { +function changeDetail(event: AuditRow): string | null { + const { before, after } = auditChange.parse(event); if (!before && !after) return null; const previous = JSON.stringify(before); const next = JSON.stringify(after); return previous && next ? `${previous} → ${next}` : next || previous; } -function exportJson(name: string, value: unknown) { +function exportJson(name: string, value: readonly AuditRow[]) { const url = URL.createObjectURL( new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }), ); diff --git a/apps/app/components/agent-builder/agent-result.tsx b/apps/app/components/agent-builder/agent-result.tsx index 29ebcac45..b857e3552 100644 --- a/apps/app/components/agent-builder/agent-result.tsx +++ b/apps/app/components/agent-builder/agent-result.tsx @@ -1,4 +1,5 @@ import { Skeleton } from "@crm/ui/components/skeleton"; +import type { EveToolOutput } from "@crm/validation/eve-tool"; import type { ReactNode } from "react"; import { anchorResults } from "@/lib/agent-results"; import { @@ -22,7 +23,7 @@ function defineResult({ skeleton, }: { tool: string; - validate: (output: unknown) => T | null; + validate: (output: EveToolOutput) => T | null; group?: ( results: readonly { itemId: string; value: T }[], ) => readonly { itemId: string; value: T }[]; @@ -58,22 +59,27 @@ const listSkeleton = ( ); -const REGISTRY: Record = { - list_deals: defineResult({ - tool: "list_deals", - validate: dealListResultOf, - group: groupDealListPages, - render: (result, key) => , - skeleton: listSkeleton, - }), -}; +const REGISTRY = new Map([ + [ + "list_deals", + defineResult({ + tool: "list_deals", + validate: dealListResultOf, + group: groupDealListPages, + render: (result, key) => ( + + ), + skeleton: listSkeleton, + }), + ], +]); export function hasAgentResult(tool: string): boolean { - return tool in REGISTRY; + return REGISTRY.has(tool); } export function agentResultSkeleton(tool: string): ReactNode { - return REGISTRY[tool]?.skeleton ?? null; + return REGISTRY.get(tool)?.skeleton ?? null; } export function agentResultsByItem( @@ -81,7 +87,7 @@ export function agentResultsByItem( ): Map { const rendered = new Map(); - for (const entry of Object.values(REGISTRY)) { + for (const entry of REGISTRY.values()) { for (const [itemId, nodes] of entry.anchor(items)) { const bucket = rendered.get(itemId); if (bucket) bucket.push(...nodes); diff --git a/apps/app/components/agent-builder/team-agent-detail.tsx b/apps/app/components/agent-builder/team-agent-detail.tsx index 93cb1f510..4bfb67771 100644 --- a/apps/app/components/agent-builder/team-agent-detail.tsx +++ b/apps/app/components/agent-builder/team-agent-detail.tsx @@ -43,7 +43,7 @@ import { import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; -import { AgentCapabilities, type Capabilities } from "./agent-capabilities"; +import { AgentCapabilities } from "./agent-capabilities"; import { AgentCode } from "./agent-code"; import { AgentRunsDrawer } from "./agent-runs-drawer"; @@ -523,8 +523,7 @@ function DeleteAgentAction({ } function AgentOverview({ agent }: { agent: AgentDetail }) { - const detail = agent as unknown as { capabilities?: Capabilities }; - const capabilities = detail.capabilities; + const { capabilities } = agent; const deployed = agent.currentVersion !== null; const canEdit = agent.canManage && deployed; diff --git a/apps/app/components/crm/agent-panel.tsx b/apps/app/components/crm/agent-panel.tsx index da2cec42f..61990ef01 100644 --- a/apps/app/components/crm/agent-panel.tsx +++ b/apps/app/components/crm/agent-panel.tsx @@ -375,17 +375,17 @@ function Failure({ message }: { message: string }) { ); } -const TONE_ICONS: Record = { +const TONE_ICONS = { neutral: CircleDash, success: Checkmark, warning: Warning, -}; +} satisfies Record; -const SOURCE_ICONS: Record = { +const SOURCE_ICONS = { linkedin: LogoLinkedin, github: LogoGithub, web: Document, -}; +} satisfies Record; function Item({ item }: { item: TranscriptItem }) { if (item.kind === "said") { @@ -503,14 +503,14 @@ function useSavedConversation({ const persist = useEffectEvent(() => { save.mutate( { - ...(contactId ? { contactId } : {}), - ...(companyId ? { companyId } : {}), - ...(dealId ? { dealId } : {}), + contactId: contactId || undefined, + companyId: companyId || undefined, + dealId: dealId || undefined, sessionId: sessionId ?? "", continuationToken: token, streamIndex, messageCount: messages, - ...(isNew ? { title: opening.current ?? undefined } : {}), + title: isNew ? (opening.current ?? undefined) : undefined, }, { onSuccess: () => { diff --git a/apps/app/components/crm/fields/field-editor.tsx b/apps/app/components/crm/fields/field-editor.tsx index 35ac13dfe..e5afe92d8 100644 --- a/apps/app/components/crm/fields/field-editor.tsx +++ b/apps/app/components/crm/fields/field-editor.tsx @@ -67,11 +67,11 @@ import { } from "./fields-copy"; import { type FieldEntity, kindOf } from "./fields-entity"; -const COVERAGE_NOUN: Record = { +const COVERAGE_NOUN = { COMPANY: "companies", CONTACT: "contacts", DEAL: "deals", -}; +} satisfies Record; type FieldRecord = RouterOutputs["fields"]["list"][number]; @@ -85,7 +85,7 @@ type Draft = { showOnTable: boolean; }; -const TYPE_HINTS: Record = { +const TYPE_HINTS = { TEXT: "Text — a short line", LONG_TEXT: "Long text — a paragraph", NUMBER: "Number", @@ -96,7 +96,7 @@ const TYPE_HINTS: Record = { EMAIL: "Email", PHONE: "Phone", USER: "User — someone in the workspace", -}; +} satisfies Record<(typeof FIELD_TYPES)[number], string>; function optionId(option: { id?: string }, index: number): string { return option.id ?? `draft-${index}`; diff --git a/apps/app/components/crm/fields/fields-copy.ts b/apps/app/components/crm/fields/fields-copy.ts index 4c809845e..9c2648f62 100644 --- a/apps/app/components/crm/fields/fields-copy.ts +++ b/apps/app/components/crm/fields/fields-copy.ts @@ -3,11 +3,11 @@ import type { FieldEntity } from "./fields-entity"; export const SHEET_TITLE = "Fields"; -const SUBTITLE: Record = { +const SUBTITLE = { company: "This shapes every company in your CRM.", contact: "This shapes every contact in your CRM.", deal: "This shapes every deal in your CRM.", -}; +} satisfies Record; export function subtitleFor(kind: RecordKind): string { return SUBTITLE[kind]; @@ -57,17 +57,17 @@ export const SAVE = "Save changes"; export const ARCHIVE = "Archive"; export const FILL_REST = "Fill the rest"; -const SHEET_PLACEMENT: Record = { +const SHEET_PLACEMENT = { COMPANY: "Show on the company sheet", CONTACT: "Show on the contact sheet", DEAL: "Show on the deal sheet", -}; +} satisfies Record; -const TABLE_PLACEMENT: Record = { +const TABLE_PLACEMENT = { COMPANY: "Offer as a column on the Companies table", CONTACT: "Offer as a column on the Contacts table", DEAL: "Offer as a column on the Deals table", -}; +} satisfies Record; export function sheetPlacement(entity: FieldEntity): string { return SHEET_PLACEMENT[entity]; diff --git a/apps/app/components/crm/fields/fields-entity.ts b/apps/app/components/crm/fields/fields-entity.ts index a7e1c88ad..290de09f2 100644 --- a/apps/app/components/crm/fields/fields-entity.ts +++ b/apps/app/components/crm/fields/fields-entity.ts @@ -2,17 +2,17 @@ import type { RecordKind } from "@/components/crm/record-sheet/record-stack"; export type FieldEntity = "COMPANY" | "CONTACT" | "DEAL"; -const TO_ENTITY: Record = { +const TO_ENTITY = { company: "COMPANY", contact: "CONTACT", deal: "DEAL", -}; +} satisfies Record; -const TO_KIND: Record = { +const TO_KIND = { COMPANY: "company", CONTACT: "contact", DEAL: "deal", -}; +} satisfies Record; export function entityOf(kind: RecordKind): FieldEntity { return TO_ENTITY[kind]; diff --git a/apps/app/components/crm/fields/standard-fields.ts b/apps/app/components/crm/fields/standard-fields.ts index 55774f5c0..5e7cc071e 100644 --- a/apps/app/components/crm/fields/standard-fields.ts +++ b/apps/app/components/crm/fields/standard-fields.ts @@ -1,6 +1,6 @@ import type { FieldEntity } from "./fields-entity"; -export const STANDARD_FIELDS: Record = { +export const STANDARD_FIELDS = { COMPANY: [ "Name", "Domain", @@ -31,4 +31,4 @@ export const STANDARD_FIELDS: Record = { "Owner", "Stage", ], -}; +} satisfies Record; diff --git a/apps/app/components/crm/record-sheet/record-actions.tsx b/apps/app/components/crm/record-sheet/record-actions.tsx index 1e13c6301..d826c36f2 100644 --- a/apps/app/components/crm/record-sheet/record-actions.tsx +++ b/apps/app/components/crm/record-sheet/record-actions.tsx @@ -31,11 +31,11 @@ import { useRecordStack, } from "./record-stack"; -const NOUN: Record = { +const NOUN = { company: "company", contact: "contact", deal: "deal", -}; +} satisfies Record; function useDeleteRecord(record: RecordRef) { const trpc = useTRPC(); diff --git a/apps/app/components/crm/record-sheet/record-stack.ts b/apps/app/components/crm/record-sheet/record-stack.ts index e4d8bec23..31dcaff3d 100644 --- a/apps/app/components/crm/record-sheet/record-stack.ts +++ b/apps/app/components/crm/record-sheet/record-stack.ts @@ -22,10 +22,10 @@ const RECORD_FORMS = ["contact", "deal"] as const; export type RecordForm = (typeof RECORD_FORMS)[number]; -const FORM_TAB: Record = { +const FORM_TAB = { contact: "contacts", deal: "deals", -}; +} satisfies Record; const params = { record: parseAsArrayOf(parseAsString, ",").withDefault([]), diff --git a/apps/app/components/crm/timeline/activity-composer.tsx b/apps/app/components/crm/timeline/activity-composer.tsx index 6ef2d612b..6cebb9918 100644 --- a/apps/app/components/crm/timeline/activity-composer.tsx +++ b/apps/app/components/crm/timeline/activity-composer.tsx @@ -34,13 +34,13 @@ const dueFormat = new Intl.DateTimeFormat("en-US", { day: "numeric", }); -const PLACEHOLDER: Record = { +const PLACEHOLDER = { NOTE: "Log a note, call, email, meeting or task…", CALL: "What came out of the call?", EMAIL: "What was said?", MEETING: "What came out of the meeting?", TASK: "What needs doing?", -}; +} satisfies Record; export function ActivityComposer({ anchor }: { anchor: TimelineAnchor }) { const trpc = useTRPC(); diff --git a/apps/app/components/crm/timeline/timeline-entry.tsx b/apps/app/components/crm/timeline/timeline-entry.tsx index 8a86f98a2..0a9d730a7 100644 --- a/apps/app/components/crm/timeline/timeline-entry.tsx +++ b/apps/app/components/crm/timeline/timeline-entry.tsx @@ -1,10 +1,12 @@ "use client"; +import { DealStage } from "@crm/db/enums"; import { Checkbox } from "@crm/ui/components/checkbox"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { cn } from "@crm/ui/lib/utils"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; +import { z } from "zod"; import { RecordLink } from "@/components/crm/record-sheet/record-link"; import { LocalDateTime, LocalRelativeTime } from "@/components/local-date-time"; import { activityLabel } from "@/lib/activity-presentation"; @@ -25,11 +27,10 @@ const TIME_OPTIONS: Intl.DateTimeFormatOptions = { minute: "2-digit", }; -function stageChange(meta: Record | null) { - const from = typeof meta?.from === "string" ? meta.from : null; - const to = typeof meta?.to === "string" ? meta.to : null; - return from && to ? { from, to } : null; -} +const stageChange = z + .object({ from: z.enum(DealStage), to: z.enum(DealStage) }) + .nullable() + .catch(null); function anchorId(anchor: TimelineAnchor): string { if ("companyId" in anchor) return anchor.companyId; @@ -62,7 +63,8 @@ export function TimelineEntry({ entry.dueAt !== null && new Date(entry.dueAt) < new Date(); - const change = entry.type === "STAGE_CHANGE" ? stageChange(entry.meta) : null; + const change = + entry.type === "STAGE_CHANGE" ? stageChange.parse(entry.meta) : null; const when = entry.occurredAt ?? entry.createdAt; const synced = entry.meta?.synced === true; @@ -73,7 +75,7 @@ export function TimelineEntry({ : entry.createdBy.name; const headline = change - ? `${dealStageLabel(change.from as never)} → ${dealStageLabel(change.to as never)}` + ? `${dealStageLabel(change.from)} → ${dealStageLabel(change.to)}` : entry.subject; const here = anchorId(anchor); diff --git a/apps/app/components/crm/timeline/timeline.tsx b/apps/app/components/crm/timeline/timeline.tsx index d2fde05f8..e84d12c81 100644 --- a/apps/app/components/crm/timeline/timeline.tsx +++ b/apps/app/components/crm/timeline/timeline.tsx @@ -31,19 +31,16 @@ export type TimelineAnchor = | { contactId: string } | { dealId: string }; -const TAB_LABELS: Record = { +const TAB_LABELS = { all: "All", notes: "Notes", email: "Email", meetings: "Meetings", upcoming: "Upcoming", done: "Done", -}; +} satisfies Record; -const EMPTY_STATES: Record< - TimelineTab, - { title: string; description: string } -> = { +const EMPTY_STATES = { all: { title: "Nothing has happened yet", description: @@ -73,16 +70,16 @@ const EMPTY_STATES: Record< title: "Nothing finished yet", description: "Tasks move here once you tick them off.", }, -}; +} satisfies Record; -const EMPTY_ICONS: Record = { +const EMPTY_ICONS = { all: Time, notes: Chat, email: Email, meetings: Events, upcoming: Task, done: Checkmark, -}; +} satisfies Record; const dayFormat = new Intl.DateTimeFormat("en-US", { weekday: "short", diff --git a/apps/app/components/inline-script.tsx b/apps/app/components/inline-script.tsx index ea27ff048..3884979b2 100644 --- a/apps/app/components/inline-script.tsx +++ b/apps/app/components/inline-script.tsx @@ -1,7 +1,7 @@ export function InlineScript({ html }: { html: string }) { return (