Split shared RPC handler by domain
This commit is contained in:
@@ -1,572 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Real RPC handler backed by SQLite database
|
* Real RPC handler backed by SQLite database.
|
||||||
* Replaces mockRpc.ts with persistent data storage
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { searchStocks, fetchQuote, getCompanyProfile } from "../data/market.js";
|
import type { RpcMethod, RpcRequestMap, RpcResult } from "@mosaiciq/contracts/rpc";
|
||||||
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 { Db } from "./database.js";
|
import type { Db } from "./database.js";
|
||||||
import type {
|
import { createRpcHandlerMap } from "../rpc/index.js";
|
||||||
RpcMethod,
|
import { fail } from "../rpc/result.js";
|
||||||
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";
|
|
||||||
|
|
||||||
export function createRpcHandler(db: Db) {
|
export function createRpcHandler(db: Db) {
|
||||||
|
const handlers = createRpcHandlerMap(db);
|
||||||
|
|
||||||
return async function handleRpc<T extends RpcMethod>(
|
return async function handleRpc<T extends RpcMethod>(
|
||||||
method: T,
|
method: T,
|
||||||
payload: RpcRequestMap[T]
|
payload: RpcRequestMap[T]
|
||||||
): Promise<RpcResult<T>> {
|
): Promise<RpcResult<T>> {
|
||||||
try {
|
try {
|
||||||
switch (method) {
|
const handler = handlers[method];
|
||||||
case "portfolio.get": {
|
if (!handler) return fail("NOT_FOUND", `Unknown RPC method: ${String(method)}`) as RpcResult<T>;
|
||||||
const portfolio = getPortfolio(db);
|
return await handler(payload as never) as RpcResult<T>;
|
||||||
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>;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[RPC] Error handling request:", error);
|
console.error("[RPC] Error handling request:", error);
|
||||||
return fail("INTERNAL_ERROR", "Unhandled RPC failure.", error) as RpcResult<T>;
|
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 } };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ CREATE TABLE IF NOT EXISTS memo_citations (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
company_id TEXT NOT NULL,
|
company_id TEXT NOT NULL,
|
||||||
section_id TEXT NOT NULL,
|
section_id TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
reference TEXT NOT NULL,
|
reference TEXT NOT NULL,
|
||||||
|
|||||||
46
packages/shared/src/rpc/agentRpc.ts
Normal file
46
packages/shared/src/rpc/agentRpc.ts
Normal 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: [] }),
|
||||||
|
};
|
||||||
|
}
|
||||||
101
packages/shared/src/rpc/companyRpc.ts
Normal file
101
packages/shared/src/rpc/companyRpc.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
16
packages/shared/src/rpc/exportRpc.ts
Normal file
16
packages/shared/src/rpc/exportRpc.ts
Normal 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) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
29
packages/shared/src/rpc/index.ts
Normal file
29
packages/shared/src/rpc/index.ts
Normal 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";
|
||||||
66
packages/shared/src/rpc/marketRpc.ts
Normal file
66
packages/shared/src/rpc/marketRpc.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
68
packages/shared/src/rpc/memoRpc.test.ts
Normal file
68
packages/shared/src/rpc/memoRpc.test.ts
Normal 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" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
62
packages/shared/src/rpc/memoRpc.ts
Normal file
62
packages/shared/src/rpc/memoRpc.ts
Normal 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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
32
packages/shared/src/rpc/modelRpc.test.ts
Normal file
32
packages/shared/src/rpc/modelRpc.test.ts
Normal 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: [] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
16
packages/shared/src/rpc/modelRpc.ts
Normal file
16
packages/shared/src/rpc/modelRpc.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
32
packages/shared/src/rpc/portfolioRpc.ts
Normal file
32
packages/shared/src/rpc/portfolioRpc.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
15
packages/shared/src/rpc/result.ts
Normal file
15
packages/shared/src/rpc/result.ts
Normal 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 } };
|
||||||
|
}
|
||||||
43
packages/shared/src/rpc/settingsRpc.test.ts
Normal file
43
packages/shared/src/rpc/settingsRpc.test.ts
Normal 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 } } });
|
||||||
|
});
|
||||||
|
});
|
||||||
22
packages/shared/src/rpc/settingsRpc.ts
Normal file
22
packages/shared/src/rpc/settingsRpc.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
9
packages/shared/src/rpc/types.ts
Normal file
9
packages/shared/src/rpc/types.ts
Normal 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>;
|
||||||
|
};
|
||||||
38
packages/shared/src/rpc/validationRpc.ts
Normal file
38
packages/shared/src/rpc/validationRpc.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
31
packages/shared/src/rpc/workspaceRpc.ts
Normal file
31
packages/shared/src/rpc/workspaceRpc.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user