diff --git a/packages/shared/src/db/rpcHandler.ts b/packages/shared/src/db/rpcHandler.ts index f4f28fa..d13cf45 100644 --- a/packages/shared/src/db/rpcHandler.ts +++ b/packages/shared/src/db/rpcHandler.ts @@ -1,572 +1,26 @@ /** - * Real RPC handler backed by SQLite database - * Replaces mockRpc.ts with persistent data storage + * Real RPC handler backed by SQLite database. */ -import { searchStocks, fetchQuote, getCompanyProfile } from "../data/market.js"; -import { fetchFilings as fetchSECFilings, getCompanyInfo as getSECCompanyInfo } from "../data/sec.js"; -import { getEarningsDate } from "../data/earnings.js"; -import { executeSourceVerification, executeModelQA, executeRedTeam, runAllValidations } from "../agents/validationAgents.js"; - +import type { RpcMethod, RpcRequestMap, RpcResult } from "@mosaiciq/contracts/rpc"; import type { Db } from "./database.js"; -import type { - RpcMethod, - RpcRequestMap, - RpcResponseMap, - RpcResult, - ModelRow, - WorkspaceSection, - ClientSettings, - ServerSettings, -} from "@mosaiciq/contracts/rpc"; -import { - getPortfolio, - addHolding as dbAddHolding, - removeHolding as dbRemoveHolding, - setActiveCompany as dbSetActiveCompany, - getCompany, - getCompanyByTicker, - searchCompanies as dbSearchCompanies, - upsertCompany, - getWorkspaceSection as dbGetWorkspaceSection, - listCatalysts, - listAlerts, - listRisks, - addRisk as dbAddRisk, - getEarningsSchedule, - listFilings, - getModel, - updateModelCell as dbUpdateModelCell, - getMemo, - updateMemoSection as dbUpdateMemoSection, - addMemoAnnotation as dbAddMemoAnnotation, - resolveMemoAnnotation as dbResolveMemoAnnotation, - updateMemoSectionReview as dbUpdateMemoSectionReview, - listAgents, - startAgent as dbStartAgent, - pauseAgent as dbPauseAgent, - restartAgent as dbRestartAgent, - listExports, - createExport as dbCreateExport, - updateExportStatus, - getClientSettings, - updateClientSettings, - getServerSettings, - updateServerSettings as dbUpdateServerSettings, - updateAgentConfig, -} from "./queries.js"; -import { DEFAULT_PORTFOLIO_ID } from "./schema.js"; +import { createRpcHandlerMap } from "../rpc/index.js"; +import { fail } from "../rpc/result.js"; export function createRpcHandler(db: Db) { + const handlers = createRpcHandlerMap(db); + return async function handleRpc( method: T, payload: RpcRequestMap[T] ): Promise> { try { - switch (method) { - case "portfolio.get": { - const portfolio = getPortfolio(db); - if (!portfolio) { - return fail("NOT_FOUND", "Portfolio not found.") as RpcResult; - } - return ok("portfolio.get", portfolio) as RpcResult; - } - - case "portfolio.addHolding": { - const { ticker } = payload as RpcRequestMap["portfolio.addHolding"]; - const company = getCompany(db, ticker.toUpperCase()); - if (!company) { - return fail("NOT_FOUND", `Company with ticker "${ticker}" not found.`) as RpcResult; - } - const holding = { - ticker: company.ticker, - name: company.name, - price: company.price, - changePct: company.changePct, - weight: 5, - }; - dbAddHolding(db, DEFAULT_PORTFOLIO_ID, holding); - return ok("portfolio.addHolding", { holding }) as RpcResult; - } - - case "portfolio.removeHolding": { - const { ticker } = payload as RpcRequestMap["portfolio.removeHolding"]; - dbRemoveHolding(db, DEFAULT_PORTFOLIO_ID, ticker.toUpperCase()); - return ok("portfolio.removeHolding", { ok: true }) as RpcResult; - } - - case "company.get": { - const { companyId } = payload as RpcRequestMap["company.get"]; - let company = getCompany(db, companyId); - - if (!company) { - // Try to fetch from external API - try { - const quote = await fetchQuote(companyId); - const profile = await getCompanyProfile(companyId); - - if (quote || profile) { - upsertCompany(db, { - ticker: companyId.toUpperCase(), - name: profile?.name || companyId.toUpperCase(), - sector: profile?.sector || "Unknown", - subIndustry: profile?.industry, - price: quote?.price || 0, - changePct: quote?.changePercent || 0, - thesis: "", - }); - - company = getCompany(db, companyId); - } - } catch (error) { - console.error("[RPC] Error fetching company:", error); - } - - if (!company) { - return fail("NOT_FOUND", `Company "${companyId}" was not found.`) as RpcResult; - } - } - - // Update with live price data - try { - const quote = await fetchQuote(company.ticker); - if (quote) { - company = { - ...company, - price: quote.price, - changePct: quote.changePercent, - }; - - // Update in database - db.prepare(` - UPDATE companies - SET price = ?, change_pct = ?, updated_at = datetime('now') - WHERE id = ? - `).run(quote.price, quote.changePercent, companyId); - } - } catch (error) { - // Use cached price if fetch fails - } - - return ok("company.get", { company }) as RpcResult; - } - - case "company.search": { - const { query } = payload as RpcRequestMap["company.search"]; - - // First search local database - const localResults = dbSearchCompanies(db, query); - - // Also search Yahoo Finance for additional results - try { - const apiResults = await searchStocks(query); - - // Store new companies in database - for (const result of apiResults) { - const existing = getCompanyByTicker(db, result.ticker); - if (!existing) { - // Fetch additional details - const profile = await getCompanyProfile(result.ticker); - - upsertCompany(db, { - ticker: result.ticker, - name: profile?.name || result.name, - sector: profile?.sector || "Unknown", - subIndustry: profile?.industry, - price: 0, - changePct: 0, - thesis: "", - }); - } - } - - // Combine results - const apiTickerSet = new Set(apiResults.map((r) => r.ticker)); - const combinedResults = [ - ...localResults.filter((r) => !apiTickerSet.has(r.ticker)), - ...apiResults.map((r) => ({ - ticker: r.ticker, - name: r.name, - sector: "Unknown", - })), - ]; - - return ok("company.search", { results: combinedResults.slice(0, 20) }) as RpcResult; - } catch (error) { - // Fallback to local results only - console.error("[RPC] Error in company.search API:", error); - return ok("company.search", { results: localResults }) as RpcResult; - } - } - - case "company.setActive": { - const { companyId } = payload as RpcRequestMap["company.setActive"]; - dbSetActiveCompany(db, DEFAULT_PORTFOLIO_ID, companyId); - return ok("company.setActive", { ok: true }) as RpcResult; - } - - case "workspace.getSection": { - const { companyId, section } = payload as RpcRequestMap["workspace.getSection"]; - const content = dbGetWorkspaceSection(db, companyId, section); - if (!content) { - // Create a default section if it doesn't exist - const newSection: WorkspaceSection = { - id: `ws-${Date.now()}`, - title: section, - content: "", - validationState: "unverified", - sourceAgent: undefined, - }; - return ok("workspace.getSection", { - content: newSection, - validationState: "unverified", - }) as RpcResult; - } - return ok("workspace.getSection", { - content, - validationState: content.validationState, - }) as RpcResult; - } - - case "workspace.listSources": { - const { companyId } = payload as RpcRequestMap["workspace.listSources"]; - const sources = [ - { type: "SEC Filing", title: "10-K FY2024", metadata: "Filed Oct 2024" }, - { type: "Earnings Transcript", title: "Q2 FY25 Call", metadata: "Mar 2025" }, - ]; - return ok("workspace.listSources", { sources }) as RpcResult; - } - - case "catalyst.list": { - const { companyId } = payload as RpcRequestMap["catalyst.list"]; - const catalysts = listCatalysts(db, companyId); - return ok("catalyst.list", { catalysts }) as RpcResult; - } - - case "alert.list": { - const params = payload as RpcRequestMap["alert.list"]; - const alerts = listAlerts(db, params.companyId, params.since); - return ok("alert.list", { alerts }) as RpcResult; - } - - case "risk.list": { - const { companyId } = payload as RpcRequestMap["risk.list"]; - const risks = listRisks(db, companyId); - return ok("risk.list", { risks }) as RpcResult; - } - - case "risk.add": { - const { companyId, risk } = payload as RpcRequestMap["risk.add"]; - const newRisk = dbAddRisk(db, companyId, risk); - return ok("risk.add", { risk: newRisk }) as RpcResult; - } - - case "earnings.getSchedule": { - const { companyId } = payload as RpcRequestMap["earnings.getSchedule"]; - let schedule = getEarningsSchedule(db, companyId); - - // If no schedule in database, fetch from API - if (schedule.length === 0) { - try { - const earningsDate = await getEarningsDate(companyId); - if (earningsDate) { - const quarter = `Q${Math.floor(earningsDate.getMonth() / 3) + 1} ${earningsDate.getFullYear()}`; - - db.prepare(` - INSERT INTO earnings_schedules (id, company_id, quarter, expected_date) - VALUES (?, ?, ?, ?) - `).run( - `earnings-${companyId}-${Date.now()}`, - companyId, - quarter, - earningsDate.toISOString() - ); - - schedule = getEarningsSchedule(db, companyId); - } - } catch (error) { - console.error("[RPC] Error fetching earnings date:", error); - } - } - - return ok("earnings.getSchedule", { schedule }) as RpcResult; - } - - case "filing.list": { - const { companyId, since } = payload as RpcRequestMap["filing.list"]; - let filings = listFilings(db, companyId, since); - - // If no filings in database, fetch from SEC - if (filings.length === 0) { - try { - const secFilings = await fetchSECFilings(companyId, { limit: 20 }); - - for (const filing of secFilings) { - db.prepare(` - INSERT INTO filings (id, company_id, form_type, filed_date, title) - VALUES (?, ?, ?, ?, ?) - `).run( - `${companyId}-${filing.formType}-${filing.filedDate}`, - companyId, - filing.formType, - filing.filedDate, - filing.title - ); - } - - filings = secFilings.map((f) => ({ - id: `${companyId}-${f.formType}-${f.filedDate}`, - companyId, - formType: f.formType, - filedDate: f.filedDate, - title: f.title, - })); - } catch (error) { - console.error("[RPC] Error fetching SEC filings:", error); - } - } - - return ok("filing.list", { filings }) as RpcResult; - } - - case "model.get": { - const { companyId, tab } = payload as RpcRequestMap["model.get"]; - const model = getModel(db, companyId, tab); - return ok("model.get", model) as RpcResult; - } - - case "model.updateCell": { - const { companyId, tab, row, col, value } = payload as RpcRequestMap[ - "model.updateCell" - ]; - const result = dbUpdateModelCell(db, companyId, tab, row, col, value); - return ok("model.updateCell", result) as RpcResult; - } - - case "model.runScenario": { - const { companyId, scenario } = payload as RpcRequestMap["model.runScenario"]; - // For now, just return the current model - // TODO: Implement scenario logic - const model = getModel(db, companyId, "income"); - return ok("model.runScenario", model) as RpcResult; - } - - case "memo.get": { - const { companyId } = payload as RpcRequestMap["memo.get"]; - const memo = getMemo(db, companyId); - return ok("memo.get", memo) as RpcResult; - } - - case "memo.updateSection": { - const { companyId, sectionId, title, content } = payload as RpcRequestMap[ - "memo.updateSection" - ]; - if (content.trim().length === 0) { - return fail("VALIDATION_ERROR", "Memo section content cannot be empty.") as RpcResult; - } - if (title !== undefined && title.trim().length === 0) { - return fail("VALIDATION_ERROR", "Memo section title cannot be empty.") as RpcResult; - } - - const section = dbUpdateMemoSection(db, companyId, sectionId, { title, content }); - const savedAt = new Date().toISOString(); - - return ok("memo.updateSection", { - section, - status: "draft", - savedAt, - }) as RpcResult; - } - - case "memo.addAnnotation": { - const { companyId, sectionId, kind, selectedText, comment } = payload as RpcRequestMap[ - "memo.addAnnotation" - ]; - if (selectedText.trim().length === 0) { - return fail("VALIDATION_ERROR", "Annotation selected text cannot be empty.") as RpcResult; - } - if (kind === "comment" && (!comment || comment.trim().length === 0)) { - return fail("VALIDATION_ERROR", "Comment annotation requires comment text.") as RpcResult; - } - - const annotation = dbAddMemoAnnotation(db, companyId, { - sectionId, - kind, - selectedText: selectedText.trim(), - comment, - createdBy: "JD", - createdAt: new Date().toISOString(), - status: "open", - }); - - return ok("memo.addAnnotation", { annotation }) as RpcResult; - } - - case "memo.resolveAnnotation": { - const { companyId, annotationId } = payload as RpcRequestMap["memo.resolveAnnotation"]; - const annotation = dbResolveMemoAnnotation(db, companyId, annotationId); - if (!annotation) { - return fail("NOT_FOUND", `Annotation "${annotationId}" not found.`) as RpcResult; - } - return ok("memo.resolveAnnotation", { annotation }) as RpcResult; - } - - case "memo.updateSectionReview": { - const { companyId, sectionId, status } = payload as RpcRequestMap[ - "memo.updateSectionReview" - ]; - const review = dbUpdateMemoSectionReview(db, companyId, sectionId, status); - return ok("memo.updateSectionReview", { review }) as RpcResult; - } - - case "memo.acceptEdit": - return ok("memo.acceptEdit", { ok: true }) as RpcResult; - - case "memo.rejectEdit": - return ok("memo.rejectEdit", { ok: true }) as RpcResult; - - case "agent.list": { - const params = payload as RpcRequestMap["agent.list"]; - const agents = listAgents(db, params.companyId); - return ok("agent.list", { agents }) as RpcResult; - } - - case "agent.start": { - const { agentId, companyId } = payload as RpcRequestMap["agent.start"]; - const { runId } = dbStartAgent(db, agentId, companyId); - return ok("agent.start", { runId }) as RpcResult; - } - - case "agent.pause": { - const { agentId } = payload as RpcRequestMap["agent.pause"]; - const result = dbPauseAgent(db, agentId); - return ok("agent.pause", result) as RpcResult; - } - - case "agent.restart": { - const { agentId } = payload as RpcRequestMap["agent.restart"]; - const { runId } = dbRestartAgent(db, agentId); - return ok("agent.restart", { runId }) as RpcResult; - } - - case "agent.chat": - return ok("agent.chat", { - response: "I've analyzed the data. Key findings: revenue growth remains on track with 5.6% YoY.", - }) as RpcResult; - - case "agent.configure": { - const { agentId, config } = payload as RpcRequestMap["agent.configure"]; - updateAgentConfig(db, agentId, config); - return ok("agent.configure", { ok: true }) as RpcResult; - } - - case "agent.getTrace": - return ok("agent.getTrace", { - steps: [ - { step: 1, label: "Load filings", detail: "Loaded 10-K and 3 quarterly reports" }, - { step: 2, label: "Extract segments", detail: "Parsed 5 revenue segments" }, - { step: 3, label: "Build model", detail: "Constructed revenue build with growth rates" }, - ], - }) as RpcResult; - - case "agent.runPipeline": { - const { companyId, pipeline } = payload as RpcRequestMap["agent.runPipeline"]; - // Start all agents in the pipeline - const runIds: string[] = []; - // TODO: Get agents for pipeline and start them - return ok("agent.runPipeline", { runIds }) as RpcResult; - } - - case "validation.run": { - const { companyId, agentType } = payload as RpcRequestMap["validation.run"]; - try { - if (!agentType || agentType === "all") { - const results = await runAllValidations(db, companyId); - return ok("validation.run", { - sourceVerification: results.sourceVerification, - modelQA: results.modelQA, - redTeam: results.redTeam, - }) as RpcResult; - } else if (agentType === "sv") { - const result = await executeSourceVerification(db, companyId); - return ok("validation.run", { sourceVerification: result }) as RpcResult; - } else if (agentType === "qa") { - const result = await executeModelQA(db, companyId); - return ok("validation.run", { modelQA: result }) as RpcResult; - } else if (agentType === "rt") { - const result = await executeRedTeam(db, companyId); - return ok("validation.run", { redTeam: result }) as RpcResult; - } - return fail("VALIDATION_ERROR", `Unknown validation agent type: ${agentType}`) as RpcResult; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return fail("INTERNAL_ERROR", `Validation failed: ${errorMsg}`, error) as RpcResult; - } - } - - case "validation.getStatus": { - const { companyId, sectionId } = payload as RpcRequestMap["validation.getStatus"]; - // For now, return unverified - in production this would check validation records - return ok("validation.getStatus", { - validationState: "unverified", - lastValidated: undefined, - }) as RpcResult; - } - - case "export.list": { - const params = payload as RpcRequestMap["export.list"]; - const exports = listExports(db, params.companyId); - return ok("export.list", { exports }) as RpcResult; - } - - case "export.create": { - const { type, companyId, options } = payload as RpcRequestMap["export.create"]; - const format = options?.format as string ?? "pdf"; - const { exportId } = dbCreateExport(db, type, `Export ${type}`, companyId, format); - return ok("export.create", { exportId }) as RpcResult; - } - - case "export.download": - return ok("export.download", { data: new ArrayBuffer(0) }) as RpcResult; - - case "settings.get": { - const { scope } = payload as RpcRequestMap["settings.get"]; - if (scope === "client") { - const settings = getClientSettings(db); - return ok("settings.get", { settings }) as RpcResult; - } - const settings = getServerSettings(db); - return ok("settings.get", { settings }) as RpcResult; - } - - case "settings.update": { - const { scope, changes } = payload as RpcRequestMap["settings.update"]; - if (scope === "client") { - updateClientSettings(db, changes as Partial); - } else { - dbUpdateServerSettings(db, changes as Partial); - } - return ok("settings.update", { ok: true }) as RpcResult; - } - - default: - return fail("NOT_FOUND", `Unknown RPC method: ${String(method)}`) as RpcResult; - } + const handler = handlers[method]; + if (!handler) return fail("NOT_FOUND", `Unknown RPC method: ${String(method)}`) as RpcResult; + return await handler(payload as never) as RpcResult; } catch (error) { console.error("[RPC] Error handling request:", error); return fail("INTERNAL_ERROR", "Unhandled RPC failure.", error) as RpcResult; } }; } - -function ok(_: T, data: RpcResponseMap[T]): RpcResult { - return { ok: true, data }; -} - -function fail( - code: "NOT_FOUND" | "VALIDATION_ERROR" | "INTERNAL_ERROR" | "AGENT_FAILED" | "CONFLICT" | "RATE_LIMITED", - message: string, - detail?: unknown -): RpcResult { - return { ok: false, error: { code, message, detail } }; -} diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index d6862e8..cd2cd62 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -211,6 +211,7 @@ CREATE TABLE IF NOT EXISTS memo_citations ( id TEXT PRIMARY KEY, company_id TEXT NOT NULL, section_id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', type TEXT NOT NULL, title TEXT NOT NULL, reference TEXT NOT NULL, diff --git a/packages/shared/src/rpc/agentRpc.ts b/packages/shared/src/rpc/agentRpc.ts new file mode 100644 index 0000000..83db08c --- /dev/null +++ b/packages/shared/src/rpc/agentRpc.ts @@ -0,0 +1,46 @@ +import type { Db } from "../db/database.js"; +import { listAgents, pauseAgent, restartAgent, startAgent, updateAgentConfig } from "../db/queries.js"; +import { ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +type AgentMethod = + | "agent.list" + | "agent.start" + | "agent.pause" + | "agent.restart" + | "agent.chat" + | "agent.configure" + | "agent.getTrace" + | "agent.runPipeline"; + +export function agentHandlers(db: Db): RpcHandlers { + return { + "agent.list": ({ companyId }) => ok("agent.list", { agents: listAgents(db, companyId) }), + "agent.start": ({ agentId, companyId }) => { + const { runId } = startAgent(db, agentId, companyId); + return ok("agent.start", { runId }); + }, + "agent.pause": ({ agentId }) => ok("agent.pause", pauseAgent(db, agentId)), + "agent.restart": ({ agentId }) => { + const { runId } = restartAgent(db, agentId); + return ok("agent.restart", { runId }); + }, + "agent.chat": () => + ok("agent.chat", { + response: "I've analyzed the data. Key findings: revenue growth remains on track with 5.6% YoY.", + }), + "agent.configure": ({ agentId, config }) => { + updateAgentConfig(db, agentId, config); + return ok("agent.configure", { ok: true }); + }, + "agent.getTrace": () => + ok("agent.getTrace", { + steps: [ + { step: 1, label: "Load filings", detail: "Loaded 10-K and 3 quarterly reports" }, + { step: 2, label: "Extract segments", detail: "Parsed 5 revenue segments" }, + { step: 3, label: "Build model", detail: "Constructed revenue build with growth rates" }, + ], + }), + "agent.runPipeline": () => ok("agent.runPipeline", { runIds: [] }), + }; +} diff --git a/packages/shared/src/rpc/companyRpc.ts b/packages/shared/src/rpc/companyRpc.ts new file mode 100644 index 0000000..7d01e58 --- /dev/null +++ b/packages/shared/src/rpc/companyRpc.ts @@ -0,0 +1,101 @@ +import type { Db } from "../db/database.js"; +import { + getCompany, + getCompanyByTicker, + searchCompanies, + setActiveCompany, + upsertCompany, +} from "../db/queries.js"; +import { DEFAULT_PORTFOLIO_ID } from "../db/schema.js"; +import { fetchQuote, getCompanyProfile, searchStocks } from "../data/market.js"; +import { fail, ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +export function companyHandlers(db: Db): RpcHandlers<"company.get" | "company.search" | "company.setActive"> { + return { + "company.get": async ({ companyId }) => { + let company = getCompany(db, companyId); + + if (!company) { + try { + const quote = await fetchQuote(companyId); + const profile = await getCompanyProfile(companyId); + + if (quote || profile) { + upsertCompany(db, { + ticker: companyId.toUpperCase(), + name: profile?.name || companyId.toUpperCase(), + sector: profile?.sector || "Unknown", + subIndustry: profile?.industry, + price: quote?.price || 0, + changePct: quote?.changePercent || 0, + thesis: "", + }); + company = getCompany(db, companyId); + } + } catch (error) { + console.error("[RPC] Error fetching company:", error); + } + + if (!company) return fail("NOT_FOUND", `Company "${companyId}" was not found.`); + } + + try { + const quote = await fetchQuote(company.ticker); + if (quote) { + company = { ...company, price: quote.price, changePct: quote.changePercent }; + db.prepare(` + UPDATE companies + SET price = ?, change_pct = ?, updated_at = datetime('now') + WHERE id = ? + `).run(quote.price, quote.changePercent, companyId); + } + } catch { + // Use cached price if refresh fails. + } + + return ok("company.get", { company }); + }, + "company.search": async ({ query }) => { + const localResults = searchCompanies(db, query); + + try { + const apiResults = await searchStocks(query); + for (const result of apiResults) { + const existing = getCompanyByTicker(db, result.ticker); + if (!existing) { + const profile = await getCompanyProfile(result.ticker); + upsertCompany(db, { + ticker: result.ticker, + name: profile?.name || result.name, + sector: profile?.sector || "Unknown", + subIndustry: profile?.industry, + price: 0, + changePct: 0, + thesis: "", + }); + } + } + + const apiTickerSet = new Set(apiResults.map((result) => result.ticker)); + const combinedResults = [ + ...localResults.filter((result) => !apiTickerSet.has(result.ticker)), + ...apiResults.map((result) => ({ + ticker: result.ticker, + name: result.name, + sector: "Unknown", + })), + ]; + + return ok("company.search", { results: combinedResults.slice(0, 20) }); + } catch (error) { + console.error("[RPC] Error in company.search API:", error); + return ok("company.search", { results: localResults }); + } + }, + "company.setActive": ({ companyId }) => { + setActiveCompany(db, DEFAULT_PORTFOLIO_ID, companyId); + return ok("company.setActive", { ok: true }); + }, + }; +} diff --git a/packages/shared/src/rpc/exportRpc.ts b/packages/shared/src/rpc/exportRpc.ts new file mode 100644 index 0000000..d9fd424 --- /dev/null +++ b/packages/shared/src/rpc/exportRpc.ts @@ -0,0 +1,16 @@ +import type { Db } from "../db/database.js"; +import { createExport, listExports } from "../db/queries.js"; +import { ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +export function exportHandlers(db: Db): RpcHandlers<"export.list" | "export.create" | "export.download"> { + return { + "export.list": ({ companyId }) => ok("export.list", { exports: listExports(db, companyId) }), + "export.create": ({ type, companyId, options }) => { + const format = (options?.format as string | undefined) ?? "pdf"; + const { exportId } = createExport(db, type, `Export ${type}`, companyId, format); + return ok("export.create", { exportId }); + }, + "export.download": () => ok("export.download", { data: new ArrayBuffer(0) }), + }; +} diff --git a/packages/shared/src/rpc/index.ts b/packages/shared/src/rpc/index.ts new file mode 100644 index 0000000..9184ae1 --- /dev/null +++ b/packages/shared/src/rpc/index.ts @@ -0,0 +1,29 @@ +import type { Db } from "../db/database.js"; +import { agentHandlers } from "./agentRpc.js"; +import { companyHandlers } from "./companyRpc.js"; +import { exportHandlers } from "./exportRpc.js"; +import { marketHandlers } from "./marketRpc.js"; +import { memoHandlers } from "./memoRpc.js"; +import { modelHandlers } from "./modelRpc.js"; +import { portfolioHandlers } from "./portfolioRpc.js"; +import { settingsHandlers } from "./settingsRpc.js"; +import type { RpcHandlerMap } from "./types.js"; +import { validationHandlers } from "./validationRpc.js"; +import { workspaceHandlers } from "./workspaceRpc.js"; + +export function createRpcHandlerMap(db: Db): RpcHandlerMap { + return { + ...portfolioHandlers(db), + ...companyHandlers(db), + ...workspaceHandlers(db), + ...marketHandlers(db), + ...modelHandlers(db), + ...memoHandlers(db), + ...agentHandlers(db), + ...validationHandlers(db), + ...exportHandlers(db), + ...settingsHandlers(db), + }; +} + +export type { RpcHandlerMap } from "./types.js"; diff --git a/packages/shared/src/rpc/marketRpc.ts b/packages/shared/src/rpc/marketRpc.ts new file mode 100644 index 0000000..efcccc3 --- /dev/null +++ b/packages/shared/src/rpc/marketRpc.ts @@ -0,0 +1,66 @@ +import type { Db } from "../db/database.js"; +import { getEarningsSchedule, listAlerts, listCatalysts, listFilings, listRisks, addRisk } from "../db/queries.js"; +import { getEarningsDate } from "../data/earnings.js"; +import { fetchFilings } from "../data/sec.js"; +import { ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +type MarketMethod = + | "catalyst.list" + | "alert.list" + | "risk.list" + | "risk.add" + | "earnings.getSchedule" + | "filing.list"; + +export function marketHandlers(db: Db): RpcHandlers { + return { + "catalyst.list": ({ companyId }) => ok("catalyst.list", { catalysts: listCatalysts(db, companyId) }), + "alert.list": ({ companyId, since }) => ok("alert.list", { alerts: listAlerts(db, companyId, since) }), + "risk.list": ({ companyId }) => ok("risk.list", { risks: listRisks(db, companyId) }), + "risk.add": ({ companyId, risk }) => ok("risk.add", { risk: addRisk(db, companyId, risk) }), + "earnings.getSchedule": async ({ companyId }) => { + let schedule = getEarningsSchedule(db, companyId); + if (schedule.length === 0) { + try { + const earningsDate = await getEarningsDate(companyId); + if (earningsDate) { + const quarter = `Q${Math.floor(earningsDate.getMonth() / 3) + 1} ${earningsDate.getFullYear()}`; + db.prepare(` + INSERT INTO earnings_schedules (id, company_id, quarter, expected_date) + VALUES (?, ?, ?, ?) + `).run(`earnings-${companyId}-${Date.now()}`, companyId, quarter, earningsDate.toISOString()); + schedule = getEarningsSchedule(db, companyId); + } + } catch (error) { + console.error("[RPC] Error fetching earnings date:", error); + } + } + return ok("earnings.getSchedule", { schedule }); + }, + "filing.list": async ({ companyId, since }) => { + let filings = listFilings(db, companyId, since); + if (filings.length === 0) { + try { + const secFilings = await fetchFilings(companyId, { limit: 20 }); + for (const filing of secFilings) { + db.prepare(` + INSERT INTO filings (id, company_id, form_type, filed_date, title) + VALUES (?, ?, ?, ?, ?) + `).run(`${companyId}-${filing.formType}-${filing.filedDate}`, companyId, filing.formType, filing.filedDate, filing.title); + } + filings = secFilings.map((filing) => ({ + id: `${companyId}-${filing.formType}-${filing.filedDate}`, + companyId, + formType: filing.formType, + filedDate: filing.filedDate, + title: filing.title, + })); + } catch (error) { + console.error("[RPC] Error fetching SEC filings:", error); + } + } + return ok("filing.list", { filings }); + }, + }; +} diff --git a/packages/shared/src/rpc/memoRpc.test.ts b/packages/shared/src/rpc/memoRpc.test.ts new file mode 100644 index 0000000..35bc059 --- /dev/null +++ b/packages/shared/src/rpc/memoRpc.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { activeCompanyId, memoSections } from "../demoData.js"; +import { closeDatabase, type Db, initDatabase } from "../db/database.js"; +import { createRpcHandler } from "../db/rpcHandler.js"; +import { seedDatabase } from "../db/seed.js"; + +describe("memo RPC", () => { + let db: Db; + let rpc: ReturnType; + + beforeEach(() => { + db = initDatabase({ inMemory: true }); + seedDatabase(db); + rpc = createRpcHandler(db); + }); + + afterEach(() => { + closeDatabase(db); + }); + + it("rejects empty memo section content", async () => { + const result = await rpc("memo.updateSection", { + companyId: activeCompanyId, + sectionId: memoSections[0].id, + content: " ", + }); + + expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION_ERROR" } }); + }); + + it("rejects empty memo section titles", async () => { + const result = await rpc("memo.updateSection", { + companyId: activeCompanyId, + sectionId: memoSections[0].id, + title: " ", + content: "Updated memo content", + }); + + expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION_ERROR" } }); + }); + + it("returns the updated section and saved timestamp after a successful update", async () => { + const result = await rpc("memo.updateSection", { + companyId: activeCompanyId, + sectionId: memoSections[0].id, + title: "Updated title", + content: "Updated memo content", + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.section).toMatchObject({ + id: memoSections[0].id, + title: "Updated title", + content: "Updated memo content", + }); + expect(result.data.savedAt).toEqual(expect.any(String)); + }); + + it("returns NOT_FOUND when resolving a missing annotation", async () => { + const result = await rpc("memo.resolveAnnotation", { + companyId: activeCompanyId, + annotationId: "missing-annotation", + }); + + expect(result).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } }); + }); +}); diff --git a/packages/shared/src/rpc/memoRpc.ts b/packages/shared/src/rpc/memoRpc.ts new file mode 100644 index 0000000..9906687 --- /dev/null +++ b/packages/shared/src/rpc/memoRpc.ts @@ -0,0 +1,62 @@ +import type { Db } from "../db/database.js"; +import { + addMemoAnnotation, + getMemo, + resolveMemoAnnotation, + updateMemoSection, + updateMemoSectionReview, +} from "../db/queries.js"; +import { fail, ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +type MemoMethod = + | "memo.get" + | "memo.updateSection" + | "memo.addAnnotation" + | "memo.resolveAnnotation" + | "memo.updateSectionReview" + | "memo.acceptEdit" + | "memo.rejectEdit"; + +export function memoHandlers(db: Db): RpcHandlers { + return { + "memo.get": ({ companyId }) => ok("memo.get", getMemo(db, companyId)), + "memo.updateSection": ({ companyId, sectionId, title, content }) => { + if (content.trim().length === 0) return fail("VALIDATION_ERROR", "Memo section content cannot be empty."); + if (title !== undefined && title.trim().length === 0) return fail("VALIDATION_ERROR", "Memo section title cannot be empty."); + + const section = updateMemoSection(db, companyId, sectionId, { title, content }); + return ok("memo.updateSection", { + section, + status: "draft", + savedAt: new Date().toISOString(), + }); + }, + "memo.addAnnotation": ({ companyId, sectionId, kind, selectedText, comment }) => { + if (selectedText.trim().length === 0) return fail("VALIDATION_ERROR", "Annotation selected text cannot be empty."); + if (kind === "comment" && (!comment || comment.trim().length === 0)) { + return fail("VALIDATION_ERROR", "Comment annotation requires comment text."); + } + + const annotation = addMemoAnnotation(db, companyId, { + sectionId, + kind, + selectedText: selectedText.trim(), + comment, + createdBy: "JD", + createdAt: new Date().toISOString(), + status: "open", + }); + return ok("memo.addAnnotation", { annotation }); + }, + "memo.resolveAnnotation": ({ companyId, annotationId }) => { + const annotation = resolveMemoAnnotation(db, companyId, annotationId); + if (!annotation) return fail("NOT_FOUND", `Annotation "${annotationId}" not found.`); + return ok("memo.resolveAnnotation", { annotation }); + }, + "memo.updateSectionReview": ({ companyId, sectionId, status }) => + ok("memo.updateSectionReview", { review: updateMemoSectionReview(db, companyId, sectionId, status) }), + "memo.acceptEdit": () => ok("memo.acceptEdit", { ok: true }), + "memo.rejectEdit": () => ok("memo.rejectEdit", { ok: true }), + }; +} diff --git a/packages/shared/src/rpc/modelRpc.test.ts b/packages/shared/src/rpc/modelRpc.test.ts new file mode 100644 index 0000000..db8efea --- /dev/null +++ b/packages/shared/src/rpc/modelRpc.test.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { activeCompanyId } from "../demoData.js"; +import { closeDatabase, type Db, initDatabase } from "../db/database.js"; +import { createRpcHandler } from "../db/rpcHandler.js"; +import { seedDatabase } from "../db/seed.js"; + +describe("model RPC", () => { + let db: Db; + let rpc: ReturnType; + + beforeEach(() => { + db = initDatabase({ inMemory: true }); + seedDatabase(db); + rpc = createRpcHandler(db); + }); + + afterEach(() => { + closeDatabase(db); + }); + + it("rejects out-of-range model cell updates without throwing", async () => { + const result = await rpc("model.updateCell", { + companyId: activeCompanyId, + tab: "income", + row: 0, + col: 999, + value: "42", + }); + + expect(result).toEqual({ ok: true, data: { ok: false, affectedCells: [] } }); + }); +}); diff --git a/packages/shared/src/rpc/modelRpc.ts b/packages/shared/src/rpc/modelRpc.ts new file mode 100644 index 0000000..e810524 --- /dev/null +++ b/packages/shared/src/rpc/modelRpc.ts @@ -0,0 +1,16 @@ +import type { Db } from "../db/database.js"; +import { getModel, updateModelCell } from "../db/queries.js"; +import { ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +export function modelHandlers(db: Db): RpcHandlers<"model.get" | "model.updateCell" | "model.runScenario"> { + return { + "model.get": ({ companyId, tab }) => ok("model.get", getModel(db, companyId, tab)), + "model.updateCell": ({ companyId, tab, row, col, value }) => + ok("model.updateCell", updateModelCell(db, companyId, tab, row, col, value)), + "model.runScenario": ({ companyId }) => { + const model = getModel(db, companyId, "income"); + return ok("model.runScenario", model); + }, + }; +} diff --git a/packages/shared/src/rpc/portfolioRpc.ts b/packages/shared/src/rpc/portfolioRpc.ts new file mode 100644 index 0000000..f6c2f02 --- /dev/null +++ b/packages/shared/src/rpc/portfolioRpc.ts @@ -0,0 +1,32 @@ +import type { Db } from "../db/database.js"; +import { addHolding, getCompany, getPortfolio, removeHolding } from "../db/queries.js"; +import { DEFAULT_PORTFOLIO_ID } from "../db/schema.js"; +import { fail, ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +export function portfolioHandlers(db: Db): RpcHandlers<"portfolio.get" | "portfolio.addHolding" | "portfolio.removeHolding"> { + return { + "portfolio.get": () => { + const portfolio = getPortfolio(db); + if (!portfolio) return fail("NOT_FOUND", "Portfolio not found."); + return ok("portfolio.get", portfolio); + }, + "portfolio.addHolding": ({ ticker }) => { + const company = getCompany(db, ticker.toUpperCase()); + if (!company) return fail("NOT_FOUND", `Company with ticker "${ticker}" not found.`); + const holding = { + ticker: company.ticker, + name: company.name, + price: company.price, + changePct: company.changePct, + weight: 5, + }; + addHolding(db, DEFAULT_PORTFOLIO_ID, holding); + return ok("portfolio.addHolding", { holding }); + }, + "portfolio.removeHolding": ({ ticker }) => { + removeHolding(db, DEFAULT_PORTFOLIO_ID, ticker.toUpperCase()); + return ok("portfolio.removeHolding", { ok: true }); + }, + }; +} diff --git a/packages/shared/src/rpc/result.ts b/packages/shared/src/rpc/result.ts new file mode 100644 index 0000000..71be67a --- /dev/null +++ b/packages/shared/src/rpc/result.ts @@ -0,0 +1,15 @@ +import type { RpcError, RpcMethod, RpcResponseMap, RpcResult } from "@mosaiciq/contracts/rpc"; + +export type RpcFailure = { ok: false; error: RpcError }; + +export function ok(_: T, data: RpcResponseMap[T]): RpcResult { + return { ok: true, data }; +} + +export function fail( + code: RpcError["code"], + message: string, + detail?: unknown +): RpcFailure { + return { ok: false, error: { code, message, detail } }; +} diff --git a/packages/shared/src/rpc/settingsRpc.test.ts b/packages/shared/src/rpc/settingsRpc.test.ts new file mode 100644 index 0000000..b69171e --- /dev/null +++ b/packages/shared/src/rpc/settingsRpc.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeDatabase, type Db, initDatabase } from "../db/database.js"; +import { createRpcHandler } from "../db/rpcHandler.js"; + +describe("settings RPC", () => { + let db: Db; + let rpc: ReturnType; + + beforeEach(() => { + db = initDatabase({ inMemory: true }); + rpc = createRpcHandler(db); + }); + + afterEach(() => { + closeDatabase(db); + }); + + it("updates client settings through the scoped settings payload", async () => { + const update = await rpc("settings.update", { + scope: "client", + changes: { theme: "dark", sidebarWidth: 320 }, + }); + expect(update).toEqual({ ok: true, data: { ok: true } }); + + const get = await rpc("settings.get", { scope: "client" }); + expect(get.ok).toBe(true); + if (!get.ok) return; + expect(get.data.settings).toMatchObject({ theme: "dark", sidebarWidth: 320 }); + }); + + it("updates persisted server agent settings through the scoped settings payload", async () => { + const update = await rpc("settings.update", { + scope: "server", + changes: { agentConfigs: { "agent-1": { enabled: true } } }, + }); + expect(update).toEqual({ ok: true, data: { ok: true } }); + + const get = await rpc("settings.get", { scope: "server" }); + expect(get.ok).toBe(true); + if (!get.ok) return; + expect(get.data.settings).toMatchObject({ agentConfigs: { "agent-1": { enabled: true } } }); + }); +}); diff --git a/packages/shared/src/rpc/settingsRpc.ts b/packages/shared/src/rpc/settingsRpc.ts new file mode 100644 index 0000000..1bfb527 --- /dev/null +++ b/packages/shared/src/rpc/settingsRpc.ts @@ -0,0 +1,22 @@ +import type { ClientSettings, ServerSettings } from "@mosaiciq/contracts/rpc"; +import type { Db } from "../db/database.js"; +import { getClientSettings, getServerSettings, updateClientSettings, updateServerSettings } from "../db/queries.js"; +import { ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +export function settingsHandlers(db: Db): RpcHandlers<"settings.get" | "settings.update"> { + return { + "settings.get": ({ scope }) => { + const settings = scope === "client" ? getClientSettings(db) : getServerSettings(db); + return ok("settings.get", { settings }); + }, + "settings.update": ({ scope, changes }) => { + if (scope === "client") { + updateClientSettings(db, changes as Partial); + } else { + updateServerSettings(db, changes as Partial); + } + return ok("settings.update", { ok: true }); + }, + }; +} diff --git a/packages/shared/src/rpc/types.ts b/packages/shared/src/rpc/types.ts new file mode 100644 index 0000000..fdbadd6 --- /dev/null +++ b/packages/shared/src/rpc/types.ts @@ -0,0 +1,9 @@ +import type { RpcMethod, RpcRequestMap, RpcResult } from "@mosaiciq/contracts/rpc"; + +export type RpcHandlerMap = { + [T in RpcMethod]: (payload: RpcRequestMap[T]) => Promise> | RpcResult; +}; + +export type RpcHandlers = { + [K in T]: (payload: RpcRequestMap[K]) => Promise> | RpcResult; +}; diff --git a/packages/shared/src/rpc/validationRpc.ts b/packages/shared/src/rpc/validationRpc.ts new file mode 100644 index 0000000..7972c5b --- /dev/null +++ b/packages/shared/src/rpc/validationRpc.ts @@ -0,0 +1,38 @@ +import type { Db } from "../db/database.js"; +import { + executeModelQA, + executeRedTeam, + executeSourceVerification, + runAllValidations, +} from "../agents/validationAgents.js"; +import { fail, ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +export function validationHandlers(db: Db): RpcHandlers<"validation.run" | "validation.getStatus"> { + return { + "validation.run": async ({ companyId, agentType }) => { + try { + if (!agentType || agentType === "all") { + const results = await runAllValidations(db, companyId); + return ok("validation.run", { + sourceVerification: results.sourceVerification, + modelQA: results.modelQA, + redTeam: results.redTeam, + }); + } + if (agentType === "sv") return ok("validation.run", { sourceVerification: await executeSourceVerification(db, companyId) }); + if (agentType === "qa") return ok("validation.run", { modelQA: await executeModelQA(db, companyId) }); + if (agentType === "rt") return ok("validation.run", { redTeam: await executeRedTeam(db, companyId) }); + return fail("VALIDATION_ERROR", `Unknown validation agent type: ${agentType}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return fail("INTERNAL_ERROR", `Validation failed: ${errorMsg}`, error); + } + }, + "validation.getStatus": () => + ok("validation.getStatus", { + validationState: "unverified", + lastValidated: undefined, + }), + }; +} diff --git a/packages/shared/src/rpc/workspaceRpc.ts b/packages/shared/src/rpc/workspaceRpc.ts new file mode 100644 index 0000000..b8083c8 --- /dev/null +++ b/packages/shared/src/rpc/workspaceRpc.ts @@ -0,0 +1,31 @@ +import type { WorkspaceSection } from "@mosaiciq/contracts/rpc"; +import type { Db } from "../db/database.js"; +import { getWorkspaceSection } from "../db/queries.js"; +import { ok } from "./result.js"; +import type { RpcHandlers } from "./types.js"; + +export function workspaceHandlers(db: Db): RpcHandlers<"workspace.getSection" | "workspace.listSources"> { + return { + "workspace.getSection": ({ companyId, section }) => { + const content = getWorkspaceSection(db, companyId, section); + if (!content) { + const newSection: WorkspaceSection = { + id: `ws-${Date.now()}`, + title: section, + content: "", + validationState: "unverified", + sourceAgent: undefined, + }; + return ok("workspace.getSection", { content: newSection, validationState: "unverified" }); + } + return ok("workspace.getSection", { content, validationState: content.validationState }); + }, + "workspace.listSources": () => { + const sources = [ + { type: "SEC Filing", title: "10-K FY2024", metadata: "Filed Oct 2024" }, + { type: "Earnings Transcript", title: "Q2 FY25 Call", metadata: "Mar 2025" }, + ]; + return ok("workspace.listSources", { sources }); + }, + }; +}