Split shared RPC handler by domain
This commit is contained in:
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
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