Implement RPC contract validation baseline

This commit is contained in:
2026-05-14 15:41:51 -04:00
parent 379c07b50c
commit df367756d0
60 changed files with 10704 additions and 47 deletions

View File

@@ -6,6 +6,10 @@
"./rpc": {
"types": "./src/rpc.ts",
"import": "./src/rpc.ts"
},
"./rpcSchemas": {
"types": "./src/rpcSchemas.ts",
"import": "./src/rpcSchemas.ts"
}
}
}

View File

@@ -85,6 +85,15 @@ export const MemoSectionReviewSchema = z.object({
});
export type MemoSectionReview = z.infer<typeof MemoSectionReviewSchema>;
export const MemoSchema = z.object({
status: z.enum(["draft", "review", "final"]),
sections: z.array(MemoSectionSchema),
citations: z.array(MemoCitationSchema),
annotations: z.array(MemoAnnotationSchema),
sectionReviews: z.array(MemoSectionReviewSchema)
});
export type Memo = z.infer<typeof MemoSchema>;
export const CatalystSchema = z.object({
id: z.string(),
date: z.string(),
@@ -174,19 +183,9 @@ export const SnapshotSchema = z.object({
});
export type Snapshot = z.infer<typeof SnapshotSchema>;
export type ClientSettings = {
theme: "light" | "dark" | "system";
density: "comfortable" | "compact" | "dense";
sidebarWidth: number;
navCollapsed: Record<string, boolean>;
keybindings: Record<string, string>;
};
export type ClientSettings = z.infer<typeof import("./rpcSchemas.js").ClientSettingsSchema>;
export type ServerSettings = {
agentConfigs: Record<string, unknown>;
dataSources: Record<string, boolean>;
exportPipelines: Record<string, unknown>;
};
export type ServerSettings = z.infer<typeof import("./rpcSchemas.js").ServerSettingsSchema>;
export type RpcRequestMap = {
"portfolio.get": undefined;
@@ -239,11 +238,13 @@ export type RpcRequestMap = {
"agent.configure": { agentId: string; config: Record<string, unknown> };
"agent.getTrace": { agentId: string; runId: string };
"agent.runPipeline": { companyId: string; pipeline: string };
"validation.run": { companyId: string; agentType?: "sv" | "qa" | "rt" | "all" };
"validation.getStatus": { companyId: string; sectionId?: string };
"export.list": { companyId?: string };
"export.create": { type: string; companyId: string; options?: Record<string, unknown> };
"export.create": { type: "pdf" | "excel" | "ppt"; companyId: string; options?: Record<string, unknown> };
"export.download": { exportId: string };
"settings.get": { scope: "client" | "server" };
"settings.update": { scope: "client" | "server"; changes: Record<string, unknown> };
"settings.update": import("./rpcSchemas.js").SettingsUpdatePayload;
};
export type RpcResponseMap = {
@@ -289,6 +290,12 @@ export type RpcResponseMap = {
"agent.configure": { ok: boolean };
"agent.getTrace": { steps: Array<{ step: number; label: string; detail: string }> };
"agent.runPipeline": { runIds: string[] };
"validation.run": {
sourceVerification?: { passed: boolean; confidence: string; issues: Array<{ severity: string; message: string; suggestion?: string }>; notes: string; timestamp: string };
modelQA?: { passed: boolean; confidence: string; issues: Array<{ severity: string; message: string; suggestion?: string }>; notes: string; timestamp: string };
redTeam?: { passed: boolean; confidence: string; issues: Array<{ severity: string; message: string; suggestion?: string }>; notes: string; timestamp: string };
};
"validation.getStatus": { validationState: "verified" | "flagged" | "unverified" | "failed"; lastValidated?: string };
"export.list": { exports: ExportRecord[] };
"export.create": { exportId: string };
"export.download": { data: ArrayBuffer };
@@ -311,3 +318,114 @@ export type RpcResult<T extends RpcMethod> =
export type RpcClient = {
call<T extends RpcMethod>(method: T, payload: RpcRequestMap[T]): Promise<RpcResult<T>>;
};
// ============== SSE Events ==============
export type ServerEventType =
| "agent.progress"
| "agent.completed"
| "agent.failed"
| "agent.started"
| "agent.streaming"
| "validation.updated"
| "memo.updated"
| "model.updated";
export type AgentProgressEvent = {
type: "agent.progress";
data: {
runId: string;
agentId: string;
companyId: string;
progress: number;
action: string;
timestamp: string;
};
};
export type AgentCompletedEvent = {
type: "agent.completed";
data: {
runId: string;
agentId: string;
companyId: string;
output: unknown;
duration: number;
timestamp: string;
};
};
export type AgentFailedEvent = {
type: "agent.failed";
data: {
runId: string;
agentId: string;
companyId: string;
error: string;
timestamp: string;
};
};
export type AgentStartedEvent = {
type: "agent.started";
data: {
runId: string;
agentId: string;
companyId: string;
pipeline?: string;
timestamp: string;
};
};
export type AgentStreamingEvent = {
type: "agent.streaming";
data: {
runId: string;
agentId: string;
companyId: string;
chunk: string;
timestamp: string;
};
};
export type ValidationUpdatedEvent = {
type: "validation.updated";
data: {
companyId: string;
sectionId?: string;
validationState: "verified" | "flagged" | "unverified" | "failed";
agentId: string;
notes?: string;
timestamp: string;
};
};
export type MemoUpdatedEvent = {
type: "memo.updated";
data: {
companyId: string;
sectionId: string;
content: string;
updatedAt: string;
};
};
export type ModelUpdatedEvent = {
type: "model.updated";
data: {
companyId: string;
tab: string;
cell?: { row: number; col: number };
timestamp: string;
};
};
export type ServerEvent =
| AgentProgressEvent
| AgentCompletedEvent
| AgentFailedEvent
| AgentStartedEvent
| AgentStreamingEvent
| ValidationUpdatedEvent
| MemoUpdatedEvent
| ModelUpdatedEvent;

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { RpcRequestSchemas, RpcResponseSchemas, parseRpcRequest } from "./rpcSchemas.js";
describe("RPC schemas", () => {
it("defines request and response schemas for every method", () => {
expect(Object.keys(RpcRequestSchemas).sort()).toEqual(Object.keys(RpcResponseSchemas).sort());
});
it("accepts valid payloads and trims bounded strings", () => {
expect(parseRpcRequest("portfolio.addHolding", { ticker: " cost " })).toEqual({ ticker: "cost" });
expect(parseRpcRequest("model.updateCell", { companyId: "cost", tab: "base", row: 0, col: 1, value: "42" })).toEqual({
companyId: "cost",
tab: "base",
row: 0,
col: 1,
value: "42",
});
});
it("rejects invalid payloads", () => {
expect(() => parseRpcRequest("portfolio.addHolding", { ticker: "" })).toThrow();
expect(() => parseRpcRequest("model.updateCell", { companyId: "cost", tab: "base", row: -1, col: 0, value: "42" })).toThrow();
});
it("rejects scope-mismatched settings updates", () => {
expect(() => parseRpcRequest("settings.update", { scope: "client", changes: { sidebarWidth: 40 } })).toThrow();
expect(() => parseRpcRequest("settings.update", { scope: "server", changes: { dataSources: { sec: "yes" } } })).toThrow();
});
it("catches malformed handler output", () => {
expect(RpcResponseSchemas["memo.updateSection"].safeParse({ section: {}, status: "draft", savedAt: "now" }).success).toBe(false);
});
});

View File

@@ -0,0 +1,270 @@
import { z } from "zod";
import {
AgentSchema,
AlertSchema,
CatalystSchema,
CompanySchema,
EarningsScheduleSchema,
ExportRecordSchema,
FilingSchema,
HoldingSchema,
MemoAnnotationSchema,
MemoCitationSchema,
MemoSectionReviewSchema,
MemoSectionSchema,
ModelRowSchema,
RiskSchema,
WorkspaceSectionSchema,
type ClientSettings,
type RpcMethod,
type RpcRequestMap,
type RpcResponseMap,
type ServerSettings,
} from "./rpc.js";
const nonEmptyString = z.string().trim().min(1);
const idString = nonEmptyString;
const tickerString = z.string().trim().min(1).max(16);
const nonNegativeIndex = z.number().int().min(0);
const unknownRecord = z.record(z.unknown());
export const ClientSettingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]),
density: z.enum(["comfortable", "compact", "dense"]),
sidebarWidth: z.number().int().min(160).max(520),
navCollapsed: z.record(z.boolean()),
keybindings: z.record(z.string()),
});
export const ServerSettingsSchema = z.object({
agentConfigs: z.record(z.unknown()),
dataSources: z.record(z.boolean()),
exportPipelines: z.record(z.unknown()),
});
const RiskInputSchema = RiskSchema.omit({ id: true, companyId: true });
const SettingsUpdateSchema = z.discriminatedUnion("scope", [
z.object({ scope: z.literal("client"), changes: ClientSettingsSchema.partial() }),
z.object({ scope: z.literal("server"), changes: ServerSettingsSchema.partial() }),
]);
export const RpcRequestSchemas = {
"portfolio.get": z.undefined(),
"portfolio.addHolding": z.object({ ticker: tickerString }),
"portfolio.removeHolding": z.object({ ticker: tickerString }),
"company.get": z.object({ companyId: idString }),
"company.search": z.object({ query: nonEmptyString }),
"company.setActive": z.object({ companyId: idString }),
"workspace.getSection": z.object({ companyId: idString, section: nonEmptyString }),
"workspace.listSources": z.object({ companyId: idString }),
"catalyst.list": z.object({ companyId: idString }),
"alert.list": z.object({ companyId: idString.optional(), since: z.string().optional() }),
"risk.list": z.object({ companyId: idString }),
"risk.add": z.object({ companyId: idString, risk: RiskInputSchema }),
"earnings.getSchedule": z.object({ companyId: idString }),
"filing.list": z.object({ companyId: idString, since: z.string().optional() }),
"model.get": z.object({ companyId: idString, tab: nonEmptyString }),
"model.updateCell": z.object({
companyId: idString,
tab: nonEmptyString,
row: nonNegativeIndex,
col: nonNegativeIndex,
value: z.string(),
}),
"model.runScenario": z.object({
companyId: idString,
scenario: nonEmptyString,
overrides: z.record(z.string()),
}),
"memo.get": z.object({ companyId: idString }),
"memo.updateSection": z.object({
companyId: idString,
sectionId: idString,
title: nonEmptyString.optional(),
content: nonEmptyString,
}),
"memo.addAnnotation": z.object({
companyId: idString,
sectionId: idString,
kind: z.enum(["highlight", "comment", "strike"]),
selectedText: nonEmptyString,
comment: nonEmptyString.optional(),
}),
"memo.resolveAnnotation": z.object({ companyId: idString, annotationId: idString }),
"memo.updateSectionReview": z.object({
companyId: idString,
sectionId: idString,
status: z.enum(["pending", "in_review", "approved", "changes_requested"]),
}),
"memo.acceptEdit": z.object({ companyId: idString, editId: idString }),
"memo.rejectEdit": z.object({ companyId: idString, editId: idString, reason: z.string().optional() }),
"agent.list": z.object({ companyId: idString.optional() }),
"agent.start": z.object({ agentId: idString, companyId: idString }),
"agent.pause": z.object({ agentId: idString }),
"agent.restart": z.object({ agentId: idString }),
"agent.chat": z.object({ agentId: idString, message: nonEmptyString }),
"agent.configure": z.object({ agentId: idString, config: unknownRecord }),
"agent.getTrace": z.object({ agentId: idString, runId: idString }),
"agent.runPipeline": z.object({
companyId: idString,
pipeline: z.enum(["research", "competitive", "cross-cutting"]),
}),
"validation.run": z.object({ companyId: idString, agentType: z.enum(["sv", "qa", "rt", "all"]).optional() }),
"validation.getStatus": z.object({ companyId: idString, sectionId: idString.optional() }),
"export.list": z.object({ companyId: idString.optional() }),
"export.create": z.object({ type: z.enum(["pdf", "excel", "ppt"]), companyId: idString, options: unknownRecord.optional() }),
"export.download": z.object({ exportId: idString }),
"settings.get": z.object({ scope: z.enum(["client", "server"]) }),
"settings.update": SettingsUpdateSchema,
} satisfies Record<RpcMethod, z.ZodTypeAny>;
const SettingsResponseSchema = z.union([ClientSettingsSchema, ServerSettingsSchema]);
const ValidationIssueSchema = z.object({
severity: z.string(),
message: z.string(),
suggestion: z.string().optional(),
});
const ValidationResultSchema = z.object({
passed: z.boolean(),
confidence: z.string(),
issues: z.array(ValidationIssueSchema),
notes: z.string(),
timestamp: z.string(),
});
export const RpcResponseSchemas = {
"portfolio.get": z.object({ id: z.string(), name: z.string(), holdings: z.array(HoldingSchema), activeCompanyId: z.string() }),
"portfolio.addHolding": z.object({ holding: HoldingSchema }),
"portfolio.removeHolding": z.object({ ok: z.boolean() }),
"company.get": z.object({ company: CompanySchema }),
"company.search": z.object({ results: z.array(z.object({ ticker: z.string(), name: z.string(), sector: z.string() })) }),
"company.setActive": z.object({ ok: z.boolean() }),
"workspace.getSection": z.object({ content: WorkspaceSectionSchema, validationState: z.string() }),
"workspace.listSources": z.object({ sources: z.array(z.object({ type: z.string(), title: z.string(), metadata: z.string() })) }),
"catalyst.list": z.object({ catalysts: z.array(CatalystSchema) }),
"alert.list": z.object({ alerts: z.array(AlertSchema) }),
"risk.list": z.object({ risks: z.array(RiskSchema) }),
"risk.add": z.object({ risk: RiskSchema }),
"earnings.getSchedule": z.object({ schedule: z.array(EarningsScheduleSchema) }),
"filing.list": z.object({ filings: z.array(FilingSchema) }),
"model.get": z.object({ headers: z.array(z.string()), rows: z.array(ModelRowSchema) }),
"model.updateCell": z.object({ ok: z.boolean(), affectedCells: z.array(z.string()) }),
"model.runScenario": z.object({ headers: z.array(z.string()), rows: z.array(ModelRowSchema) }),
"memo.get": z.object({
status: z.enum(["draft", "review", "final"]),
sections: z.array(MemoSectionSchema),
citations: z.array(MemoCitationSchema),
annotations: z.array(MemoAnnotationSchema),
sectionReviews: z.array(MemoSectionReviewSchema),
}),
"memo.updateSection": z.object({
section: MemoSectionSchema,
status: z.enum(["draft", "review", "final"]),
savedAt: z.string(),
}),
"memo.addAnnotation": z.object({ annotation: MemoAnnotationSchema }),
"memo.resolveAnnotation": z.object({ annotation: MemoAnnotationSchema }),
"memo.updateSectionReview": z.object({ review: MemoSectionReviewSchema }),
"memo.acceptEdit": z.object({ ok: z.boolean() }),
"memo.rejectEdit": z.object({ ok: z.boolean() }),
"agent.list": z.object({ agents: z.array(AgentSchema) }),
"agent.start": z.object({ runId: z.string() }),
"agent.pause": z.object({ ok: z.boolean() }),
"agent.restart": z.object({ runId: z.string() }),
"agent.chat": z.object({ response: z.string() }),
"agent.configure": z.object({ ok: z.boolean() }),
"agent.getTrace": z.object({ steps: z.array(z.object({ step: z.number(), label: z.string(), detail: z.string() })) }),
"agent.runPipeline": z.object({ runIds: z.array(z.string()) }),
"validation.run": z.object({
sourceVerification: ValidationResultSchema.optional(),
modelQA: ValidationResultSchema.optional(),
redTeam: ValidationResultSchema.optional(),
}),
"validation.getStatus": z.object({
validationState: z.enum(["verified", "flagged", "unverified", "failed"]),
lastValidated: z.string().optional(),
}),
"export.list": z.object({ exports: z.array(ExportRecordSchema) }),
"export.create": z.object({ exportId: z.string() }),
"export.download": z.object({ data: z.instanceof(ArrayBuffer) }),
"settings.get": z.object({ settings: SettingsResponseSchema }),
"settings.update": z.object({ ok: z.boolean() }),
} satisfies Record<RpcMethod, z.ZodTypeAny>;
export const AgentProgressEventSchema = z.object({
type: z.literal("agent.progress"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), progress: z.number(), action: z.string(), timestamp: z.string() }),
});
export const AgentCompletedEventSchema = z.object({
type: z.literal("agent.completed"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), output: z.unknown(), duration: z.number(), timestamp: z.string() }),
});
export const AgentFailedEventSchema = z.object({
type: z.literal("agent.failed"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), error: z.string(), timestamp: z.string() }),
});
export const AgentStartedEventSchema = z.object({
type: z.literal("agent.started"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), pipeline: z.string().optional(), timestamp: z.string() }),
});
export const AgentStreamingEventSchema = z.object({
type: z.literal("agent.streaming"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), chunk: z.string(), timestamp: z.string() }),
});
export const ValidationUpdatedEventSchema = z.object({
type: z.literal("validation.updated"),
data: z.object({
companyId: z.string(),
sectionId: z.string().optional(),
validationState: z.enum(["verified", "flagged", "unverified", "failed"]),
agentId: z.string(),
notes: z.string().optional(),
timestamp: z.string(),
}),
});
export const MemoUpdatedEventSchema = z.object({
type: z.literal("memo.updated"),
data: z.object({ companyId: z.string(), sectionId: z.string(), content: z.string(), updatedAt: z.string() }),
});
export const ModelUpdatedEventSchema = z.object({
type: z.literal("model.updated"),
data: z.object({ companyId: z.string(), tab: z.string(), cell: z.object({ row: z.number(), col: z.number() }).optional(), timestamp: z.string() }),
});
export const ServerEventSchema = z.discriminatedUnion("type", [
AgentProgressEventSchema,
AgentCompletedEventSchema,
AgentFailedEventSchema,
AgentStartedEventSchema,
AgentStreamingEventSchema,
ValidationUpdatedEventSchema,
MemoUpdatedEventSchema,
ModelUpdatedEventSchema,
]);
export const ServerEventTypeSchema = z.enum([
"agent.progress",
"agent.completed",
"agent.failed",
"agent.started",
"agent.streaming",
"validation.updated",
"memo.updated",
"model.updated",
]);
export function isRpcMethod(method: unknown): method is RpcMethod {
return typeof method === "string" && method in RpcRequestSchemas;
}
export function parseRpcRequest<T extends RpcMethod>(method: T, payload: unknown): RpcRequestMap[T] {
return RpcRequestSchemas[method].parse(payload) as RpcRequestMap[T];
}
export function parseRpcResponse<T extends RpcMethod>(method: T, data: unknown): RpcResponseMap[T] {
return RpcResponseSchemas[method].parse(data) as RpcResponseMap[T];
}
export type SettingsUpdatePayload =
| { scope: "client"; changes: Partial<ClientSettings> }
| { scope: "server"; changes: Partial<ServerSettings> };