Split shared RPC handler by domain

This commit is contained in:
2026-05-14 15:50:28 -04:00
parent df367756d0
commit 4aa3f7b362
18 changed files with 636 additions and 555 deletions

View File

@@ -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<T extends RpcMethod>(
method: T,
payload: RpcRequestMap[T]
): Promise<RpcResult<T>> {
try {
switch (method) {
case "portfolio.get": {
const portfolio = getPortfolio(db);
if (!portfolio) {
return fail("NOT_FOUND", "Portfolio not found.") as RpcResult<T>;
}
return ok("portfolio.get", portfolio) as RpcResult<T>;
}
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<T>;
}
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<T>;
}
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<T>;
}
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<T>;
}
}
// 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<T>;
}
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<T>;
} catch (error) {
// Fallback to local results only
console.error("[RPC] Error in company.search API:", error);
return ok("company.search", { results: localResults }) as RpcResult<T>;
}
}
case "company.setActive": {
const { companyId } = payload as RpcRequestMap["company.setActive"];
dbSetActiveCompany(db, DEFAULT_PORTFOLIO_ID, companyId);
return ok("company.setActive", { ok: true }) as RpcResult<T>;
}
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<T>;
}
return ok("workspace.getSection", {
content,
validationState: content.validationState,
}) as RpcResult<T>;
}
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<T>;
}
case "catalyst.list": {
const { companyId } = payload as RpcRequestMap["catalyst.list"];
const catalysts = listCatalysts(db, companyId);
return ok("catalyst.list", { catalysts }) as RpcResult<T>;
}
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<T>;
}
case "risk.list": {
const { companyId } = payload as RpcRequestMap["risk.list"];
const risks = listRisks(db, companyId);
return ok("risk.list", { risks }) as RpcResult<T>;
}
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<T>;
}
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<T>;
}
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<T>;
}
case "model.get": {
const { companyId, tab } = payload as RpcRequestMap["model.get"];
const model = getModel(db, companyId, tab);
return ok("model.get", model) as RpcResult<T>;
}
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<T>;
}
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<T>;
}
case "memo.get": {
const { companyId } = payload as RpcRequestMap["memo.get"];
const memo = getMemo(db, companyId);
return ok("memo.get", memo) as RpcResult<T>;
}
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<T>;
}
if (title !== undefined && title.trim().length === 0) {
return fail("VALIDATION_ERROR", "Memo section title cannot be empty.") as RpcResult<T>;
}
const section = dbUpdateMemoSection(db, companyId, sectionId, { title, content });
const savedAt = new Date().toISOString();
return ok("memo.updateSection", {
section,
status: "draft",
savedAt,
}) as RpcResult<T>;
}
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<T>;
}
if (kind === "comment" && (!comment || comment.trim().length === 0)) {
return fail("VALIDATION_ERROR", "Comment annotation requires comment text.") as RpcResult<T>;
}
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<T>;
}
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<T>;
}
return ok("memo.resolveAnnotation", { annotation }) as RpcResult<T>;
}
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<T>;
}
case "memo.acceptEdit":
return ok("memo.acceptEdit", { ok: true }) as RpcResult<T>;
case "memo.rejectEdit":
return ok("memo.rejectEdit", { ok: true }) as RpcResult<T>;
case "agent.list": {
const params = payload as RpcRequestMap["agent.list"];
const agents = listAgents(db, params.companyId);
return ok("agent.list", { agents }) as RpcResult<T>;
}
case "agent.start": {
const { agentId, companyId } = payload as RpcRequestMap["agent.start"];
const { runId } = dbStartAgent(db, agentId, companyId);
return ok("agent.start", { runId }) as RpcResult<T>;
}
case "agent.pause": {
const { agentId } = payload as RpcRequestMap["agent.pause"];
const result = dbPauseAgent(db, agentId);
return ok("agent.pause", result) as RpcResult<T>;
}
case "agent.restart": {
const { agentId } = payload as RpcRequestMap["agent.restart"];
const { runId } = dbRestartAgent(db, agentId);
return ok("agent.restart", { runId }) as RpcResult<T>;
}
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<T>;
case "agent.configure": {
const { agentId, config } = payload as RpcRequestMap["agent.configure"];
updateAgentConfig(db, agentId, config);
return ok("agent.configure", { ok: true }) as RpcResult<T>;
}
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<T>;
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<T>;
}
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<T>;
} else if (agentType === "sv") {
const result = await executeSourceVerification(db, companyId);
return ok("validation.run", { sourceVerification: result }) as RpcResult<T>;
} else if (agentType === "qa") {
const result = await executeModelQA(db, companyId);
return ok("validation.run", { modelQA: result }) as RpcResult<T>;
} else if (agentType === "rt") {
const result = await executeRedTeam(db, companyId);
return ok("validation.run", { redTeam: result }) as RpcResult<T>;
}
return fail("VALIDATION_ERROR", `Unknown validation agent type: ${agentType}`) as RpcResult<T>;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return fail("INTERNAL_ERROR", `Validation failed: ${errorMsg}`, error) as RpcResult<T>;
}
}
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<T>;
}
case "export.list": {
const params = payload as RpcRequestMap["export.list"];
const exports = listExports(db, params.companyId);
return ok("export.list", { exports }) as RpcResult<T>;
}
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<T>;
}
case "export.download":
return ok("export.download", { data: new ArrayBuffer(0) }) as RpcResult<T>;
case "settings.get": {
const { scope } = payload as RpcRequestMap["settings.get"];
if (scope === "client") {
const settings = getClientSettings(db);
return ok("settings.get", { settings }) as RpcResult<T>;
}
const settings = getServerSettings(db);
return ok("settings.get", { settings }) as RpcResult<T>;
}
case "settings.update": {
const { scope, changes } = payload as RpcRequestMap["settings.update"];
if (scope === "client") {
updateClientSettings(db, changes as Partial<ClientSettings>);
} else {
dbUpdateServerSettings(db, changes as Partial<ServerSettings>);
}
return ok("settings.update", { ok: true }) as RpcResult<T>;
}
default:
return fail("NOT_FOUND", `Unknown RPC method: ${String(method)}`) as RpcResult<T>;
}
const handler = handlers[method];
if (!handler) return fail("NOT_FOUND", `Unknown RPC method: ${String(method)}`) as RpcResult<T>;
return await handler(payload as never) as RpcResult<T>;
} catch (error) {
console.error("[RPC] Error handling request:", error);
return fail("INTERNAL_ERROR", "Unhandled RPC failure.", error) as RpcResult<T>;
}
};
}
function ok<T extends RpcMethod>(_: T, data: RpcResponseMap[T]): RpcResult<T> {
return { ok: true, data };
}
function fail<T extends RpcMethod>(
code: "NOT_FOUND" | "VALIDATION_ERROR" | "INTERNAL_ERROR" | "AGENT_FAILED" | "CONFLICT" | "RATE_LIMITED",
message: string,
detail?: unknown
): RpcResult<T> {
return { ok: false, error: { code, message, detail } };
}

View File

@@ -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,

View File

@@ -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<AgentMethod> {
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: [] }),
};
}

View File

@@ -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 });
},
};
}

View File

@@ -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) }),
};
}

View File

@@ -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";

View File

@@ -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<MarketMethod> {
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 });
},
};
}

View File

@@ -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<typeof createRpcHandler>;
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" } });
});
});

View File

@@ -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<MemoMethod> {
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 }),
};
}

View File

@@ -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<typeof createRpcHandler>;
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: [] } });
});
});

View File

@@ -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);
},
};
}

View File

@@ -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 });
},
};
}

View File

@@ -0,0 +1,15 @@
import type { RpcError, RpcMethod, RpcResponseMap, RpcResult } from "@mosaiciq/contracts/rpc";
export type RpcFailure = { ok: false; error: RpcError };
export function ok<const T extends RpcMethod>(_: T, data: RpcResponseMap[T]): RpcResult<T> {
return { ok: true, data };
}
export function fail(
code: RpcError["code"],
message: string,
detail?: unknown
): RpcFailure {
return { ok: false, error: { code, message, detail } };
}

View File

@@ -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<typeof createRpcHandler>;
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 } } });
});
});

View File

@@ -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<ClientSettings>);
} else {
updateServerSettings(db, changes as Partial<ServerSettings>);
}
return ok("settings.update", { ok: true });
},
};
}

View File

@@ -0,0 +1,9 @@
import type { RpcMethod, RpcRequestMap, RpcResult } from "@mosaiciq/contracts/rpc";
export type RpcHandlerMap = {
[T in RpcMethod]: (payload: RpcRequestMap[T]) => Promise<RpcResult<T>> | RpcResult<T>;
};
export type RpcHandlers<T extends RpcMethod> = {
[K in T]: (payload: RpcRequestMap[K]) => Promise<RpcResult<K>> | RpcResult<K>;
};

View File

@@ -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,
}),
};
}

View File

@@ -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 });
},
};
}