diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index 9ae724e..65980f6 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -554,10 +554,30 @@ ipcMain.handle('export:markdown', async (_event, input: unknown) => toIpcResult(() => agentKernelHost.exportMarkdown(input)) ); +ipcMain.handle('background:listJobs', async () => toIpcResult(() => agentKernelHost.backgroundListJobs())); +ipcMain.handle('background:saveJob', async (_event, input: unknown) => toIpcResult(() => agentKernelHost.backgroundSaveJob(input))); +ipcMain.handle('background:setEnabled', async (_event, input: unknown) => toIpcResult(() => agentKernelHost.backgroundSetEnabled(input))); +ipcMain.handle('background:removeJob', async (_event, input: unknown) => toIpcResult(() => agentKernelHost.backgroundRemoveJob(input))); +ipcMain.handle('background:listRuns', async (_event, input: unknown) => toIpcResult(() => agentKernelHost.backgroundListRuns(input))); +ipcMain.handle('background:listNotifications', async () => toIpcResult(() => agentKernelHost.backgroundListNotifications())); + +ipcMain.handle('export:html', async (_event, input: unknown) => + toIpcResult(() => agentKernelHost.exportHtml(input)) +); + +ipcMain.handle('export:json', async (_event, input: unknown) => + toIpcResult(() => agentKernelHost.exportJson(input)) +); + ipcMain.handle('export:shareCard', async (_event, input: unknown) => toIpcResult(() => agentKernelHost.exportShareCard(input)) ); +ipcMain.handle('context:list', async () => toIpcResult(() => agentKernelHost.contextList())); +ipcMain.handle('context:saveWatchlist', async (_event, input: unknown) => toIpcResult(() => agentKernelHost.contextSaveWatchlist(input))); +ipcMain.handle('context:savePortfolio', async (_event, input: unknown) => toIpcResult(() => agentKernelHost.contextSavePortfolio(input))); +ipcMain.handle('context:getRun', async (_event, input: unknown) => toIpcResult(() => agentKernelHost.contextGetRun(input))); + // Controlled external open (spec §10): http/https only, never a shell. ipcMain.handle('openExternal', async (_event, url: unknown) => toIpcResult(async () => { diff --git a/apps/electron/src/main/kernelHost.test.ts b/apps/electron/src/main/kernelHost.test.ts index 1123e31..9981fed 100644 --- a/apps/electron/src/main/kernelHost.test.ts +++ b/apps/electron/src/main/kernelHost.test.ts @@ -254,8 +254,31 @@ mock.module('@finagent/shared', () => ({ THESIS_REVIEW_HOUR: 9, WEEKDAYS: [1, 2, 3, 4, 5], reportToMarkdown: () => '', + reportToHtml: () => '', + reportToJson: () => '{}', reportToShareCard: () => ({ svg: '', text: '' }), redactForShare: (report: unknown) => report, + PortfolioContextRepository: class { + listWatchlists = async () => []; + listPortfolios = async () => []; + saveWatchlist = async (input: unknown) => input; + savePortfolio = async (input: unknown) => input; + snapshot = async () => ({ id: 'ctx-1', kind: 'watchlist', sourceId: 'wl', sourceVersion: 1, createdAt: 1, document: {} }); + bindSnapshots = async () => ({ runId: 'r1', sessionId: 's1', branchId: 'main', snapshotIds: [], boundAt: 1 }); + getRunContext = async () => undefined; + get = async () => undefined; + }, + BackgroundJobRepository: class { + listJobs = async () => []; + listRuns = async () => []; + listNotifications = async () => []; + saveJob = async (input: unknown) => input; + setEnabled = async () => undefined; + removeJob = async () => undefined; + }, + BackgroundJobScheduler: class { + tick = async () => undefined; + }, computeSkillCalibrations: () => [], computeStrategyCalibrations: () => [], // V7 evaluation/observability (kernelHost constructor wiring; spec §15). @@ -457,4 +480,11 @@ describe('AgentKernelHost', () => { }); host.dispose(); }); + + it('validates context and background job payloads at the IPC boundary', async () => { + const host = new AgentKernelHost(); + await expect(host.contextSaveWatchlist({ id: 'wl', name: 'Bad', instruments: [{ instrumentId: 'AAPL' }] })).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' }); + await expect(host.backgroundSaveJob({ id: 'job', type: 'research' })).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' }); + host.dispose(); + }); }); diff --git a/apps/electron/src/main/kernelHost.ts b/apps/electron/src/main/kernelHost.ts index 2b4bed1..5b3530e 100644 --- a/apps/electron/src/main/kernelHost.ts +++ b/apps/electron/src/main/kernelHost.ts @@ -70,6 +70,11 @@ import type { ToolCall, ToolCallRecord, TraceReference, + BackgroundJob, + StoredNotification, + ContextSelection, + VersionedWatchlist, + VersionedPortfolioContext, } from '@finagent/core'; import { DEFAULT_INSTRUMENT_CATALOG, InstrumentResolver, STRATEGY_IDS } from '@finagent/core'; import { isLocalePreference } from '@finagent/i18n'; @@ -128,6 +133,8 @@ import { parseCsv, parsePaste, reportToMarkdown, + reportToHtml, + reportToJson, reportToShareCard, redactForShare, computeSkillCalibrations, @@ -155,6 +162,9 @@ import { type MarketPulseSnapshot, type ShareCard, type WatchlistQuote, + PortfolioContextRepository, + BackgroundJobRepository, + BackgroundJobScheduler, withDemoDataFallback, } from '@finagent/shared'; import { @@ -266,10 +276,13 @@ export class AgentKernelHost { private readonly screeningService: ScreeningService; private readonly diffRepository: ResearchDiffRepository; private readonly importRepository: ManualPortfolioRepository; + private readonly contextRepository: PortfolioContextRepository; private readonly pulseService: PulseService; private readonly performanceService: PerformanceService; private readonly automationRules: AutomationRuleRepository; private readonly automationRuns: AutomationRunRepository; + private readonly backgroundJobs: BackgroundJobRepository; + private readonly backgroundScheduler: BackgroundJobScheduler; private automationTimer: ReturnType | null = null; private readonly lastAutomationRunByRule = new Map(); private readonly thesisRepository: ThesisRepository; @@ -298,6 +311,7 @@ export class AgentKernelHost { private traceCorrelation: TraceCorrelationService; private readonly evalRuns = new Map(); private unsubscribeEval: (() => void) | null = null; + private disposed = false; constructor() { // Resource paths come from ResourceLocator: the repo root in dev, the app @@ -394,6 +408,7 @@ export class AgentKernelHost { }); this.diffRepository = new ResearchDiffRepository(new JsonFileStore(userData)); this.importRepository = new ManualPortfolioRepository(new JsonFileStore(userData)); + this.contextRepository = new PortfolioContextRepository(new JsonFileStore(userData)); // V5 market pulse + performance (spec §50–52, §36–38). this.pulseService = new PulseService({ @@ -406,6 +421,12 @@ export class AgentKernelHost { // V5 scheduled research (spec §21–25): five default rules, seeded once. this.automationRules = new AutomationRuleRepository(new JsonFileStore(userData)); this.automationRuns = new AutomationRunRepository(new JsonFileStore(userData)); + this.backgroundJobs = new BackgroundJobRepository(new JsonFileStore(userData)); + this.backgroundScheduler = new BackgroundJobScheduler(this.backgroundJobs, { + run: async (job) => this.executeBackgroundJob(job), + }, { + notify: (notification) => this.dispatchStoredNotification(notification), + }); void this.seedAutomationRules(); this.thesisRepository = new ThesisRepository({ storageDir: join(userData, 'thesis') }); @@ -534,17 +555,58 @@ export class AgentKernelHost { entry.toUpperCase() ); } + if (typeof context.branchId === 'string' && context.branchId.trim()) { + workspaceContext.branchId = context.branchId.trim(); + } + if (Array.isArray(context.contextSelections)) { + workspaceContext.contextSelections = context.contextSelections.map((value, index) => + parseSelection(value, `workspaceContext.contextSelections[${index}]`) + ).filter((selection): selection is ContextSelection => selection !== undefined); + } } // V8: new agent responses follow the *effective* app locale unless the // user explicitly requests another language in the prompt (spec §41–42). // Resolved after validation and with a safe fallback so a prefs failure // can never block an agent run (failure-isolation, spec §87). - return this.kernel.runs.startRun( - requireString(request.sessionId, 'sessionId'), + const sessionId = requireString(request.sessionId, 'sessionId'); + const snapshots = []; + for (const selection of workspaceContext?.contextSelections ?? []) { + snapshots.push(await this.contextRepository.snapshot(selection, workspaceContext?.activeSymbol ? [workspaceContext.activeSymbol] : undefined)); + } + if (snapshots.length > 0 && workspaceContext) workspaceContext.contextSnapshots = snapshots; + const run = await this.kernel.runs.startRun( + sessionId, requireString(request.content, 'content'), workspaceContext, await this.effectiveRunLocale() ); + if (snapshots.length > 0) { + try { + await this.contextRepository.bindSnapshots({ runId: run.id, sessionId, branchId: workspaceContext?.branchId ?? 'main', snapshots }); + } catch (error) { + await this.kernel.runs.cancelRun(sessionId, run.id).catch(() => undefined); + throw error; + } + } + return run; + } + + async contextList(): Promise<{ watchlists: import('@finagent/core').VersionedWatchlist[]; portfolios: import('@finagent/core').VersionedPortfolioContext[] }> { + const [watchlists, portfolios] = await Promise.all([this.contextRepository.listWatchlists(), this.contextRepository.listPortfolios()]); + return { watchlists, portfolios }; + } + + async contextSaveWatchlist(input: unknown): Promise { + return this.contextRepository.saveWatchlist(parseWatchlistInput(input)); + } + + async contextSavePortfolio(input: unknown): Promise { + return this.contextRepository.savePortfolio(parsePortfolioInput(input)); + } + + async contextGetRun(input: unknown): Promise { + const request = requireObject(input); + return this.contextRepository.getRunContext(requireString(request.runId, 'runId'), requireString(request.sessionId, 'sessionId'), requireString(request.branchId, 'branchId')); } private async effectiveRunLocale(): Promise { @@ -2251,6 +2313,76 @@ export class AgentKernelHost { return reportToMarkdown(redactForShare(report)); } + async backgroundListJobs(): Promise { return this.backgroundJobs.listJobs(); } + async backgroundSaveJob(input: unknown): Promise { return this.backgroundJobs.saveJob(parseBackgroundJobInput(input)); } + async backgroundSetEnabled(input: unknown): Promise { + const request = requireObject(input); + return this.backgroundJobs.setEnabled(requireString(request.jobId, 'jobId'), request.enabled === true); + } + async backgroundRemoveJob(input: unknown): Promise { await this.backgroundJobs.removeJob(requireString(requireObject(input).jobId, 'jobId')); } + async backgroundListRuns(input: unknown): Promise { + const request = requireObject(input); + return this.backgroundJobs.listRuns(typeof request.jobId === 'string' ? request.jobId : undefined); + } + async backgroundListNotifications(): Promise { return this.backgroundJobs.listNotifications(); } + + private async executeBackgroundJob(job: BackgroundJob): Promise<{ productionRunId?: string; notificationKind?: StoredNotification['kind'] }> { + const explicitSymbol = typeof job.input.symbol === 'string' ? job.input.symbol.toUpperCase() : undefined; + const target = job.targetContext ? await this.contextRepository.get(job.targetContext) : undefined; + const contextSymbols = target + ? ('instruments' in target ? target.instruments.map((item) => item.instrumentId) : target.positions.map((item) => item.instrumentId)) + : []; + const symbols = [...new Set([...(explicitSymbol ? [explicitSymbol] : []), ...contextSymbols])]; + if (symbols.length === 0) throw createCodeError('BACKGROUND_INPUT_INVALID', 'A canonical symbol or non-empty target context is required.'); + let productionRunId: string | undefined; + for (const symbol of symbols) { + const summary = await this.researchService.start(symbol, undefined, await this.effectiveRunLocale()); + const finished = await this.waitForResearchRun(summary.id); + if (finished.status === 'failed' || finished.status === 'cancelled') throw createCodeError('BACKGROUND_RESEARCH_FAILED', `Research ${finished.status} for ${symbol}.`); + productionRunId = finished.reportId ?? finished.id; + } + return { productionRunId, notificationKind: job.type === 'filing-check' ? 'filing-found' : job.type === 'watchlist-digest' ? 'digest-ready' : 'completed' }; + } + + private async waitForResearchRun(runId: string): Promise { + const deadline = Date.now() + 30 * 60_000; + while (!this.disposed && Date.now() < deadline) { + const summary = await this.researchService.getRun(runId); + if (summary && ['completed', 'partial', 'failed', 'cancelled'].includes(summary.status)) return summary; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw createCodeError( + this.disposed ? 'BACKGROUND_RESEARCH_CANCELLED' : 'BACKGROUND_RESEARCH_TIMEOUT', + this.disposed ? 'Background research was cancelled during shutdown.' : 'Research exceeded the background task deadline.' + ); + } + + private dispatchStoredNotification(notification: StoredNotification): void { + this.dispatchNotification({ + id: notification.id, + source: 'automation', + title: notification.title, + message: notification.message, + at: notification.createdAt, + severity: notification.kind === 'failed' ? 'warning' : 'info', + payload: { jobId: notification.jobId, runId: notification.runId, deepLink: notification.deepLink }, + }); + } + + async exportHtml(input: unknown): Promise { + const request = requireObject(input); + const report = await this.researchService.getReport(requireString(request.reportId, 'reportId')); + if (!report) throw createCodeError('REPORT_NOT_FOUND', 'Unknown research report.'); + return reportToHtml(redactForShare(report)); + } + + async exportJson(input: unknown): Promise { + const request = requireObject(input); + const report = await this.researchService.getReport(requireString(request.reportId, 'reportId')); + if (!report) throw createCodeError('REPORT_NOT_FOUND', 'Unknown research report.'); + return reportToJson(redactForShare(report)); + } + async exportShareCard(input: unknown): Promise { const request = requireObject(input); const report = await this.researchService.getReport(requireString(request.reportId, 'reportId')); @@ -2323,7 +2455,9 @@ export class AgentKernelHost { private startAutomationScheduler(): void { this.automationTimer = setInterval(() => { void this.tickAutomations(); + void this.backgroundScheduler.tick().catch(() => undefined); }, 60_000); + void this.backgroundScheduler.tick().catch(() => undefined); } private async tickAutomations(): Promise { @@ -2376,7 +2510,15 @@ export class AgentKernelHost { private dispatchNotification(event: NotificationEvent): void { try { if (Notification.isSupported()) { - new Notification({ title: event.title, body: event.message }).show(); + const notification = new Notification({ title: event.title, body: event.message }); + notification.on('click', () => { + if (!this.window || this.window.isDestroyed()) return; + this.window.show(); + this.window.focus(); + const deepLink = event.payload?.deepLink; + if (typeof deepLink === 'string' && deepLink.startsWith('/')) this.window.webContents.send('notification:open', deepLink); + }); + notification.show(); } } catch { // OS notification best-effort. @@ -2543,6 +2685,9 @@ export class AgentKernelHost { } async dispose() { + this.disposed = true; + if (this.automationTimer) clearInterval(this.automationTimer); + this.automationTimer = null; this.alertEngine.stop(); this.unsubscribe?.(); this.unsubscribe = null; @@ -2654,6 +2799,98 @@ function requireObject(value: unknown): Record { return value as Record; } +const CANONICAL_INSTRUMENT = /^[A-Z0-9]{1,5}\.(US|HK|SG|SH|SZ|HAS)$/; +const BACKGROUND_TYPES = new Set(['research', 'watchlist-digest', 'filing-check']); +const BACKGROUND_STATUSES = new Set(['scheduled', 'running', 'succeeded', 'failed', 'cancelled', 'missed']); + +function boundedString(value: unknown, field: string, max = 240): string { + const result = requireString(value, field).trim(); + if (result.length > max) throw createCodeError('INVALID_ARGUMENT', `${field} is too long.`); + return result; +} + +function finiteNumber(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) throw createCodeError('INVALID_ARGUMENT', `${field} must be a finite number.`); + return value; +} + +function canonicalInstrument(value: unknown, field: string): string { + const instrument = boundedString(value, field, 32).toUpperCase(); + if (!CANONICAL_INSTRUMENT.test(instrument)) throw createCodeError('INVALID_ARGUMENT', `${field} must be a canonical instrument id.`); + return instrument; +} + +function parseSelection(value: unknown, field = 'targetContext'): ContextSelection | undefined { + if (value === undefined) return undefined; + const input = requireObject(value); + if (input.kind !== 'watchlist' && input.kind !== 'portfolio') throw createCodeError('INVALID_ARGUMENT', `${field}.kind is invalid.`); + return { kind: input.kind, id: boundedString(input.id, `${field}.id`, 120) }; +} + +function parseWatchlistInput(value: unknown): Omit { + const input = requireObject(value); + const instruments = input.instruments; + if (!Array.isArray(instruments) || instruments.length > 500) throw createCodeError('INVALID_ARGUMENT', 'instruments must contain at most 500 entries.'); + return { + id: boundedString(input.id, 'id', 120), + name: boundedString(input.name, 'name'), + instruments: instruments.map((entry, index) => { + const item = requireObject(entry); + return { instrumentId: canonicalInstrument(item.instrumentId, `instruments[${index}].instrumentId`) }; + }), + }; +} + +function parsePortfolioInput(value: unknown): Omit { + const input = requireObject(value); + if (!Array.isArray(input.positions) || input.positions.length > 500) throw createCodeError('INVALID_ARGUMENT', 'positions must contain at most 500 entries.'); + return { + id: boundedString(input.id, 'id', 120), + name: boundedString(input.name, 'name'), + asOf: finiteNumber(input.asOf, 'asOf'), + positions: input.positions.map((entry, index) => { + const item = requireObject(entry); + const optionalNumber = (field: string): number | undefined => item[field] === undefined ? undefined : finiteNumber(item[field], `positions[${index}].${field}`); + return { + instrumentId: canonicalInstrument(item.instrumentId, `positions[${index}].instrumentId`), + nativeCurrency: boundedString(item.nativeCurrency, `positions[${index}].nativeCurrency`, 12), + quantity: optionalNumber('quantity'), weight: optionalNumber('weight'), costBasis: optionalNumber('costBasis'), + }; + }), + }; +} + +function parseBackgroundJobInput(value: unknown): BackgroundJob { + const input = requireObject(value); + if (typeof input.type !== 'string' || !BACKGROUND_TYPES.has(input.type as BackgroundJob['type'])) throw createCodeError('INVALID_ARGUMENT', 'type is not a supported background job type.'); + const schedule = requireObject(input.schedule); + const retryPolicy = requireObject(input.retryPolicy); + const notificationPolicy = requireObject(input.notificationPolicy); + const status = input.status; + if (typeof status !== 'string' || !BACKGROUND_STATUSES.has(status as BackgroundJob['status'])) throw createCodeError('INVALID_ARGUMENT', 'status is invalid.'); + if (typeof input.enabled !== 'boolean') throw createCodeError('INVALID_ARGUMENT', 'enabled must be boolean.'); + if (typeof input.input !== 'object' || input.input === null || Array.isArray(input.input)) throw createCodeError('INVALID_ARGUMENT', 'input must be an object.'); + const jobInput = input.input as Record; + if (typeof jobInput.symbol === 'string') canonicalInstrument(jobInput.symbol, 'input.symbol'); + if (JSON.stringify(jobInput).length > 8_192) throw createCodeError('INVALID_ARGUMENT', 'input is too large.'); + const intervalMs = finiteNumber(schedule.intervalMs, 'schedule.intervalMs'); + const maxAttempts = finiteNumber(retryPolicy.maxAttempts, 'retryPolicy.maxAttempts'); + const initialBackoffMs = finiteNumber(retryPolicy.initialBackoffMs, 'retryPolicy.initialBackoffMs'); + const maxBackoffMs = finiteNumber(retryPolicy.maxBackoffMs, 'retryPolicy.maxBackoffMs'); + if (intervalMs < 1_000 || intervalMs > 365 * 24 * 60 * 60_000 || !Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10 || initialBackoffMs < 0 || initialBackoffMs > 24 * 60 * 60_000 || maxBackoffMs < initialBackoffMs || maxBackoffMs > 24 * 60 * 60_000) throw createCodeError('INVALID_ARGUMENT', 'schedule/retry policy is outside supported bounds.'); + if (typeof notificationPolicy.onSuccess !== 'boolean' || typeof notificationPolicy.onFailure !== 'boolean' || typeof notificationPolicy.sensitivePreview !== 'boolean') throw createCodeError('INVALID_ARGUMENT', 'notificationPolicy flags must be boolean.'); + return { + id: boundedString(input.id, 'id', 120), type: input.type as BackgroundJob['type'], enabled: input.enabled, + schedule: { intervalMs }, input: jobInput, targetContext: parseSelection(input.targetContext), + createdAt: finiteNumber(input.createdAt, 'createdAt'), nextRunAt: finiteNumber(input.nextRunAt, 'nextRunAt'), + ...(input.lastRunAt === undefined ? {} : { lastRunAt: finiteNumber(input.lastRunAt, 'lastRunAt') }), + ...(input.lastRunId === undefined ? {} : { lastRunId: boundedString(input.lastRunId, 'lastRunId', 120) }), + status: status as BackgroundJob['status'], retryPolicy: { maxAttempts, initialBackoffMs, maxBackoffMs }, + missedRunPolicy: input.missedRunPolicy === 'skip' ? 'skip' : input.missedRunPolicy === 'catch-up' ? 'catch-up' : (() => { throw createCodeError('INVALID_ARGUMENT', 'missedRunPolicy is invalid.'); })(), + notificationPolicy: { onSuccess: notificationPolicy.onSuccess, onFailure: notificationPolicy.onFailure, sensitivePreview: notificationPolicy.sensitivePreview }, + }; +} + function createCodeError(code: string, message: string, action?: string) { const error = new Error(message) as Error & { code: string; action?: string }; error.code = code; diff --git a/apps/electron/src/preload/index.cjs b/apps/electron/src/preload/index.cjs index fd46cb4..e16075e 100644 --- a/apps/electron/src/preload/index.cjs +++ b/apps/electron/src/preload/index.cjs @@ -211,8 +211,29 @@ var electronAPI = { }, export: { markdown: (input) => import_electron.ipcRenderer.invoke("export:markdown", input), + html: (input) => import_electron.ipcRenderer.invoke("export:html", input), + json: (input) => import_electron.ipcRenderer.invoke("export:json", input), shareCard: (input) => import_electron.ipcRenderer.invoke("export:shareCard", input) }, + background: { + listJobs: () => import_electron.ipcRenderer.invoke("background:listJobs"), + saveJob: (input) => import_electron.ipcRenderer.invoke("background:saveJob", input), + setEnabled: (input) => import_electron.ipcRenderer.invoke("background:setEnabled", input), + removeJob: (input) => import_electron.ipcRenderer.invoke("background:removeJob", input), + listRuns: (input) => import_electron.ipcRenderer.invoke("background:listRuns", input), + listNotifications: () => import_electron.ipcRenderer.invoke("background:listNotifications"), + onOpen: (callback) => { + const listener = (_event, deepLink) => callback(deepLink); + import_electron.ipcRenderer.on("notification:open", listener); + return () => import_electron.ipcRenderer.removeListener("notification:open", listener); + } + }, + context: { + list: () => import_electron.ipcRenderer.invoke("context:list"), + saveWatchlist: (input) => import_electron.ipcRenderer.invoke("context:saveWatchlist", input), + savePortfolio: (input) => import_electron.ipcRenderer.invoke("context:savePortfolio", input), + getRun: (input) => import_electron.ipcRenderer.invoke("context:getRun", input) + }, evaluation: { getSettings: () => import_electron.ipcRenderer.invoke("evaluation:getSettings"), setSettings: (input) => import_electron.ipcRenderer.invoke("evaluation:setSettings", input), diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 4a0d283..af2d55a 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -166,8 +166,25 @@ export interface ElectronAPI { }; export: { markdown: (input: { reportId: string }) => Promise; + html: (input: { reportId: string }) => Promise; + json: (input: { reportId: string }) => Promise; shareCard: (input: { reportId: string }) => Promise; }; + background: { + listJobs: () => Promise; + saveJob: (input: unknown) => Promise; + setEnabled: (input: { jobId: string; enabled: boolean }) => Promise; + removeJob: (input: { jobId: string }) => Promise; + listRuns: (input: { jobId?: string }) => Promise; + listNotifications: () => Promise; + onOpen: (callback: (deepLink: string) => void) => () => void; + }; + context: { + list: () => Promise; + saveWatchlist: (input: unknown) => Promise; + savePortfolio: (input: unknown) => Promise; + getRun: (input: { runId: string; sessionId: string; branchId: string }) => Promise; + }; evaluation: { getSettings: () => Promise; setSettings: (input: unknown) => Promise; @@ -380,8 +397,29 @@ const electronAPI: ElectronAPI = { }, export: { markdown: (input: { reportId: string }) => ipcRenderer.invoke('export:markdown', input), + html: (input: { reportId: string }) => ipcRenderer.invoke('export:html', input), + json: (input: { reportId: string }) => ipcRenderer.invoke('export:json', input), shareCard: (input: { reportId: string }) => ipcRenderer.invoke('export:shareCard', input), }, + background: { + listJobs: () => ipcRenderer.invoke('background:listJobs'), + saveJob: (input: unknown) => ipcRenderer.invoke('background:saveJob', input), + setEnabled: (input: { jobId: string; enabled: boolean }) => ipcRenderer.invoke('background:setEnabled', input), + removeJob: (input: { jobId: string }) => ipcRenderer.invoke('background:removeJob', input), + listRuns: (input: { jobId?: string }) => ipcRenderer.invoke('background:listRuns', input), + listNotifications: () => ipcRenderer.invoke('background:listNotifications'), + onOpen: (callback: (deepLink: string) => void) => { + const listener = (_event: unknown, deepLink: string) => callback(deepLink); + ipcRenderer.on('notification:open', listener); + return () => ipcRenderer.removeListener('notification:open', listener); + }, + }, + context: { + list: () => ipcRenderer.invoke('context:list'), + saveWatchlist: (input: unknown) => ipcRenderer.invoke('context:saveWatchlist', input), + savePortfolio: (input: unknown) => ipcRenderer.invoke('context:savePortfolio', input), + getRun: (input: { runId: string; sessionId: string; branchId: string }) => ipcRenderer.invoke('context:getRun', input), + }, evaluation: { getSettings: () => ipcRenderer.invoke('evaluation:getSettings'), setSettings: (input) => ipcRenderer.invoke('evaluation:setSettings', input), diff --git a/artifacts/issues-36-38-e2e/e2e-evidence.json b/artifacts/issues-36-38-e2e/e2e-evidence.json new file mode 100644 index 0000000..660f806 --- /dev/null +++ b/artifacts/issues-36-38-e2e/e2e-evidence.json @@ -0,0 +1,204 @@ +{ + "generatedAt": 1789113452103, + "liveAvailability": [ + { + "url": "https://nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_source=folio", + "status": 200 + }, + { + "url": "https://www.nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_campaign=earnings#financial-highlights", + "status": "unreachable" + }, + { + "url": "https://data.sec.gov/submissions/CIK0001045810.json", + "status": 200 + }, + { + "url": "https://www.reuters.com/technology/artificial-intelligence/nvidia-forecasts-first-quarter-revenue-above-estimates-2025-02-26/", + "status": "unreachable" + }, + { + "url": "https://finance.example.test/nvidia-results-copy-a", + "status": "unreachable" + }, + { + "url": "https://markets.example.test/nvidia-results-copy-b", + "status": "unreachable" + } + ], + "sources": [ + { + "id": "nvidia-ir", + "url": "https://nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_source=folio", + "title": "NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025", + "summary": "Quarterly revenue was $39.3 billion, up 12% from Q3 and up 78% from a year ago.", + "publisher": "NVIDIA Newsroom", + "publishedAt": 1740603600000, + "available": true + }, + { + "id": "nvidia-ir-tracked", + "url": "https://www.nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_campaign=earnings#financial-highlights", + "title": "NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025", + "summary": "Quarterly revenue was $39.3 billion, up 12% from Q3 and up 78% from a year ago.", + "publisher": "NVIDIA Newsroom mirror", + "publishedAt": 1740603600000, + "available": false + }, + { + "id": "sec-submissions", + "url": "https://data.sec.gov/submissions/CIK0001045810.json", + "title": "NVIDIA Corporation SEC submissions", + "summary": "Official filing history and accession metadata for NVIDIA Corporation.", + "publisher": "U.S. SEC", + "publishedAt": 1740607200000, + "available": true + }, + { + "id": "reuters-original", + "url": "https://www.reuters.com/technology/artificial-intelligence/nvidia-forecasts-first-quarter-revenue-above-estimates-2025-02-26/", + "title": "Nvidia forecasts first-quarter revenue above estimates", + "summary": "Nvidia forecast quarterly revenue above Wall Street estimates as demand for AI chips remained strong.", + "publisher": "Reuters", + "publishedAt": 1740607800000, + "available": false + }, + { + "id": "wire-copy-a", + "url": "https://finance.example.test/nvidia-results-copy-a", + "title": "Nvidia forecasts first quarter revenue above estimates", + "summary": "Nvidia forecast quarterly revenue above Wall Street estimates as demand for AI chips remained strong.", + "publisher": "Syndication A", + "publishedAt": 1740608100000, + "available": false + }, + { + "id": "wire-copy-b", + "url": "https://markets.example.test/nvidia-results-copy-b", + "title": "Nvidia forecasts Q1 revenue above estimates", + "summary": "Nvidia forecast quarterly revenue above Wall Street estimates as demand for AI chips remained strong.", + "publisher": "Syndication B", + "publishedAt": 1740608400000, + "available": false + } + ], + "context": { + "instrumentCount": 5, + "snapshotIds": [ + "ctx-watchlist-ai-watchlist-v1-1789113452103-df109f6b-1c5a-4841-abfb-18014b993e7f", + "ctx-portfolio-core-portfolio-v1-1789113452103-039eaef8-32be-4e69-b61e-9909cda12a80" + ], + "historicalSnapshotVersion": 1, + "currentPortfolioVersion": 2 + }, + "background": { + "jobs": [ + { + "id": "failure-check", + "type": "filing-check", + "enabled": true, + "schedule": { + "intervalMs": 86400000 + }, + "input": { + "symbol": "NVDA.US" + }, + "targetContext": { + "kind": "watchlist", + "id": "ai-watchlist" + }, + "createdAt": 1789113451103, + "nextRunAt": 1789199852103, + "status": "failed", + "retryPolicy": { + "maxAttempts": 2, + "initialBackoffMs": 1, + "maxBackoffMs": 2 + }, + "missedRunPolicy": "catch-up", + "notificationPolicy": { + "onSuccess": true, + "onFailure": true, + "sensitivePreview": false + }, + "lastRunAt": 1789113452103, + "lastRunId": "6726935d-f906-4953-84f5-1f2df94ebc29" + }, + { + "id": "nvda-filing-check", + "type": "filing-check", + "enabled": true, + "schedule": { + "intervalMs": 86400000 + }, + "input": { + "symbol": "NVDA.US" + }, + "targetContext": { + "kind": "watchlist", + "id": "ai-watchlist" + }, + "createdAt": 1789113451103, + "nextRunAt": 1789199852103, + "status": "succeeded", + "retryPolicy": { + "maxAttempts": 2, + "initialBackoffMs": 1, + "maxBackoffMs": 2 + }, + "missedRunPolicy": "catch-up", + "notificationPolicy": { + "onSuccess": true, + "onFailure": true, + "sensitivePreview": false + }, + "lastRunAt": 1789113452103, + "lastRunId": "9fa476a9-c243-4e2c-bb50-9308f39f5c40" + } + ], + "runs": [ + { + "id": "6726935d-f906-4953-84f5-1f2df94ebc29", + "jobId": "failure-check", + "status": "failed", + "scheduledFor": 1789113452102, + "startedAt": 1789113452103, + "finishedAt": 1789113452103, + "attempts": 2, + "error": "simulated provider failure" + }, + { + "id": "9fa476a9-c243-4e2c-bb50-9308f39f5c40", + "jobId": "nvda-filing-check", + "status": "succeeded", + "scheduledFor": 1789113452102, + "startedAt": 1789113452103, + "finishedAt": 1789113452103, + "attempts": 1, + "productionRunId": "report-nvda-fy2025-e2e" + } + ], + "notifications": [ + { + "id": "notification-6726935d-f906-4953-84f5-1f2df94ebc29", + "jobId": "failure-check", + "runId": "6726935d-f906-4953-84f5-1f2df94ebc29", + "kind": "failed", + "title": "Research needs attention", + "message": "Open Folio for details.", + "createdAt": 1789113452103, + "deepLink": "/jobs/failure-check" + }, + { + "id": "notification-9fa476a9-c243-4e2c-bb50-9308f39f5c40", + "jobId": "nvda-filing-check", + "runId": "9fa476a9-c243-4e2c-bb50-9308f39f5c40", + "kind": "filing-found", + "title": "New filing found", + "message": "Open Folio to view the result.", + "createdAt": 1789113452103, + "deepLink": "/runs/report-nvda-fy2025-e2e" + } + ] + } +} diff --git a/artifacts/issues-36-38-e2e/nvda-fy2025-report.html b/artifacts/issues-36-38-e2e/nvda-fy2025-report.html new file mode 100644 index 0000000..f7c6684 --- /dev/null +++ b/artifacts/issues-36-38-e2e/nvda-fy2025-report.html @@ -0,0 +1 @@ +NVDA.US Research Report

NVDA.US Research Report

Generated 2026-09-11T07:57:32.103Z · bullish · 84%

NVIDIA reported fiscal Q4 2025 revenue of $39.3B, up 78% year over year. The report keeps the live Web and financial evidence references, including source availability and structured period/currency/unit metadata.

Earnings event

positive

MetricValueAs of
Revenue$39.3BFY2025 Q4
YoY growth78%FY2025 Q4
[1] [2] [3] [4]

Financial evidence

positive

Quarterly revenue and growth were checked as structured values with explicit unit and period.

[5]

Evidence

  1. [1] NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025
    NVIDIA Newsroom · run live-search-e2e · 2026-09-11T07:57:32.103Z
  2. [2] NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025
    NVIDIA Newsroom mirror · run live-search-e2e · 2026-09-11T07:57:32.103Z
  3. [3] NVIDIA Corporation SEC submissions
    U.S. SEC · run live-search-e2e · 2026-09-11T07:57:32.103Z
  4. [4] Nvidia forecasts first-quarter revenue above estimates
    Reuters · run live-search-e2e · 2026-09-11T07:57:32.103Z
  5. [5] Fiscal Q4 revenue was $39.3B and grew 78% YoY.
    NVDA.US · quarterly_revenue · USD · billions · run financial-e2e · 2026-09-11T07:57:32.103Z
Folio export · schema folio-report-export-v1 · run e2e-copilot-run
\ No newline at end of file diff --git a/artifacts/issues-36-38-e2e/nvda-fy2025-report.json b/artifacts/issues-36-38-e2e/nvda-fy2025-report.json new file mode 100644 index 0000000..dc7ff38 --- /dev/null +++ b/artifacts/issues-36-38-e2e/nvda-fy2025-report.json @@ -0,0 +1,249 @@ +{ + "schemaVersion": "folio-report-export-v1", + "exportedAt": 1789113452103, + "report": { + "id": "report-nvda-fy2025-e2e", + "symbol": "NVDA.US", + "generatedAt": 1789113452103, + "strategyId": "earnings", + "locale": "en-US", + "summary": "NVIDIA reported fiscal Q4 2025 revenue of $39.3B, up 78% year over year. The report keeps the live Web and financial evidence references, including source availability and structured period/currency/unit metadata.", + "stance": "bullish", + "confidence": 0.84, + "sections": [ + { + "key": "research.news", + "title": "Earnings event", + "verdict": "positive", + "summary": "| Metric | Value | As of |\n|---|---:|---|\n| Revenue | $39.3B | FY2025 Q4 |\n| YoY growth | 78% | FY2025 Q4 |", + "evidence": [ + { + "capabilityId": "research.news", + "runId": "live-search-e2e", + "claim": "NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025", + "fetchedAt": 1789113452103, + "summary": "Quarterly revenue was $39.3 billion, up 12% from Q3 and up 78% from a year ago.", + "sourceId": "nvidia-ir", + "sourceUrl": "https://nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_source=folio", + "provider": "NVIDIA Newsroom", + "status": "available" + }, + { + "capabilityId": "research.news", + "runId": "live-search-e2e", + "claim": "NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025", + "fetchedAt": 1789113452103, + "summary": "Quarterly revenue was $39.3 billion, up 12% from Q3 and up 78% from a year ago.", + "sourceId": "nvidia-ir-tracked", + "sourceUrl": "https://www.nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_campaign=earnings#financial-highlights", + "provider": "NVIDIA Newsroom mirror", + "status": "unavailable" + }, + { + "capabilityId": "research.news", + "runId": "live-search-e2e", + "claim": "NVIDIA Corporation SEC submissions", + "fetchedAt": 1789113452103, + "summary": "Official filing history and accession metadata for NVIDIA Corporation.", + "sourceId": "sec-submissions", + "sourceUrl": "https://data.sec.gov/submissions/CIK0001045810.json", + "provider": "U.S. SEC", + "status": "available" + }, + { + "capabilityId": "research.news", + "runId": "live-search-e2e", + "claim": "Nvidia forecasts first-quarter revenue above estimates", + "fetchedAt": 1789113452103, + "summary": "Nvidia forecast quarterly revenue above Wall Street estimates as demand for AI chips remained strong.", + "sourceId": "reuters-original", + "sourceUrl": "https://www.reuters.com/technology/artificial-intelligence/nvidia-forecasts-first-quarter-revenue-above-estimates-2025-02-26/", + "provider": "Reuters", + "status": "unavailable" + } + ] + }, + { + "key": "company.financials", + "title": "Financial evidence", + "verdict": "positive", + "summary": "Quarterly revenue and growth were checked as structured values with explicit unit and period.", + "evidence": [ + { + "capabilityId": "company.financials", + "runId": "financial-e2e", + "claim": "Fiscal Q4 revenue was $39.3B and grew 78% YoY.", + "fetchedAt": 1789113452103, + "instrumentId": "NVDA.US", + "metric": "quarterly_revenue", + "asOf": 1737849600000, + "currency": "USD", + "unit": "billions", + "status": "available" + } + ] + } + ], + "bullCase": [ + "AI accelerator demand remained strong." + ], + "bearCase": [ + "Supply and customer concentration remain material risks." + ], + "catalysts": [ + "Blackwell production ramp." + ], + "risks": [ + "Demand normalization and export controls." + ], + "capabilityRuns": [ + { + "runId": "live-search-e2e", + "capabilityId": "research.news", + "status": "success", + "fetchedAt": 1789113452103 + }, + { + "runId": "financial-e2e", + "capabilityId": "company.financials", + "status": "success", + "fetchedAt": 1789113452103 + } + ], + "runStatus": "completed", + "runManifest": { + "runId": "e2e-copilot-run", + "model": "deterministic-e2e", + "configVersion": "issues-36-38-v1", + "contextSnapshotIds": [ + "ctx-watchlist-ai-watchlist-v1-1789113452103-df109f6b-1c5a-4841-abfb-18014b993e7f", + "ctx-portfolio-core-portfolio-v1-1789113452103-039eaef8-32be-4e69-b61e-9909cda12a80" + ] + } + }, + "claims": [ + { + "sectionKey": "research.news", + "sectionTitle": "Earnings event", + "claim": "| Metric | Value | As of |\n|---|---:|---|\n| Revenue | $39.3B | FY2025 Q4 |\n| YoY growth | 78% | FY2025 Q4 |", + "citationNumbers": [ + 1, + 2, + 3, + 4 + ] + }, + { + "sectionKey": "company.financials", + "sectionTitle": "Financial evidence", + "claim": "Quarterly revenue and growth were checked as structured values with explicit unit and period.", + "citationNumbers": [ + 5 + ] + } + ], + "citations": [ + { + "number": 1, + "claim": "NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025", + "capabilityId": "research.news", + "runId": "live-search-e2e", + "fetchedAt": 1789113452103, + "source": { + "id": "nvidia-ir", + "url": "https://nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_source=folio", + "provider": "NVIDIA Newsroom" + }, + "financial": {}, + "status": "available" + }, + { + "number": 2, + "claim": "NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025", + "capabilityId": "research.news", + "runId": "live-search-e2e", + "fetchedAt": 1789113452103, + "source": { + "id": "nvidia-ir-tracked", + "url": "https://www.nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_campaign=earnings#financial-highlights", + "provider": "NVIDIA Newsroom mirror" + }, + "financial": {}, + "status": "unavailable" + }, + { + "number": 3, + "claim": "NVIDIA Corporation SEC submissions", + "capabilityId": "research.news", + "runId": "live-search-e2e", + "fetchedAt": 1789113452103, + "source": { + "id": "sec-submissions", + "url": "https://data.sec.gov/submissions/CIK0001045810.json", + "provider": "U.S. SEC" + }, + "financial": {}, + "status": "available" + }, + { + "number": 4, + "claim": "Nvidia forecasts first-quarter revenue above estimates", + "capabilityId": "research.news", + "runId": "live-search-e2e", + "fetchedAt": 1789113452103, + "source": { + "id": "reuters-original", + "url": "https://www.reuters.com/technology/artificial-intelligence/nvidia-forecasts-first-quarter-revenue-above-estimates-2025-02-26/", + "provider": "Reuters" + }, + "financial": {}, + "status": "unavailable" + }, + { + "number": 5, + "claim": "Fiscal Q4 revenue was $39.3B and grew 78% YoY.", + "capabilityId": "company.financials", + "runId": "financial-e2e", + "fetchedAt": 1789113452103, + "financial": { + "instrumentId": "NVDA.US", + "metric": "quarterly_revenue", + "asOf": 1737849600000, + "currency": "USD", + "unit": "billions" + }, + "status": "available" + } + ], + "sources": [ + { + "id": "nvidia-ir", + "url": "https://nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_source=folio", + "provider": "NVIDIA Newsroom" + }, + { + "id": "nvidia-ir-tracked", + "url": "https://www.nvidianews.nvidia.com/news/nvidia-announces-financial-results-for-fourth-quarter-and-fiscal-2025?utm_campaign=earnings#financial-highlights", + "provider": "NVIDIA Newsroom mirror" + }, + { + "id": "sec-submissions", + "url": "https://data.sec.gov/submissions/CIK0001045810.json", + "provider": "U.S. SEC" + }, + { + "id": "reuters-original", + "url": "https://www.reuters.com/technology/artificial-intelligence/nvidia-forecasts-first-quarter-revenue-above-estimates-2025-02-26/", + "provider": "Reuters" + } + ], + "runManifest": { + "runId": "e2e-copilot-run", + "model": "deterministic-e2e", + "configVersion": "issues-36-38-v1", + "contextSnapshotIds": [ + "ctx-watchlist-ai-watchlist-v1-1789113452103-df109f6b-1c5a-4841-abfb-18014b993e7f", + "ctx-portfolio-core-portfolio-v1-1789113452103-039eaef8-32be-4e69-b61e-9909cda12a80" + ] + } +} diff --git a/artifacts/issues-36-38-e2e/nvda-fy2025-report.md b/artifacts/issues-36-38-e2e/nvda-fy2025-report.md new file mode 100644 index 0000000..fde06ff --- /dev/null +++ b/artifacts/issues-36-38-e2e/nvda-fy2025-report.md @@ -0,0 +1,46 @@ +# NVDA.US — Research Report + +**BULLISH** · Confidence: 84% · Strategy: Earnings · Generated 2026-09-11T07:57:32.103Z + +NVIDIA reported fiscal Q4 2025 revenue of $39.3B, up 78% year over year. The report keeps the live Web and financial evidence references, including source availability and structured period/currency/unit metadata. + +## Sections + +### Earnings event — Positive + +| Metric | Value | As of | +|---|---:|---| +| Revenue | $39.3B | FY2025 Q4 | +| YoY growth | 78% | FY2025 Q4 | + +### Financial evidence — Positive + +Quarterly revenue and growth were checked as structured values with explicit unit and period. + +## Bull Case + +- AI accelerator demand remained strong. + +## Bear Case + +- Supply and customer concentration remain material risks. + +## Catalysts + +- Blackwell production ramp. + +## Risks + +- Demand normalization and export controls. + +## Evidence + +- Earnings event: NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025 — research.news (run live-search-e2e) + +- Earnings event: NVIDIA Announces Financial Results for Fourth Quarter and Fiscal 2025 — research.news (run live-search-e2e) + +- Earnings event: NVIDIA Corporation SEC submissions — research.news (run live-search-e2e) + +- Earnings event: Nvidia forecasts first-quarter revenue above estimates — research.news (run live-search-e2e) + +- Financial evidence: Fiscal Q4 revenue was $39.3B and grew 78% YoY. — company.financials (run financial-e2e) diff --git a/artifacts/issues-36-38-e2e/nvda-fy2025-report.png b/artifacts/issues-36-38-e2e/nvda-fy2025-report.png new file mode 100644 index 0000000..18274fe Binary files /dev/null and b/artifacts/issues-36-38-e2e/nvda-fy2025-report.png differ diff --git a/docs/ui-evidence/folio-context-selection-after.png b/docs/ui-evidence/folio-context-selection-after.png new file mode 100644 index 0000000..be0ade8 Binary files /dev/null and b/docs/ui-evidence/folio-context-selection-after.png differ diff --git a/docs/ui-evidence/folio-export-menu-after.png b/docs/ui-evidence/folio-export-menu-after.png new file mode 100644 index 0000000..f0d0eb4 Binary files /dev/null and b/docs/ui-evidence/folio-export-menu-after.png differ diff --git a/package.json b/package.json index 4ff9240..baa9f66 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "release:check": "bun run scripts/release-check.mjs", "release:package": "bun run scripts/release-package.mjs", "i18n:check": "bun scripts/i18n-check.ts", - "trace:showcase": "cd apps/electron && node e2e/trace-showcase.mjs" + "trace:showcase": "cd apps/electron && node e2e/trace-showcase.mjs", + "e2e:issues-36-38": "bun scripts/eval/issues-36-38-e2e.ts" }, "devDependencies": { "@earendil-works/pi-coding-agent": "file:.pi/extensions/langsmith/vendor/pi-coding-agent", diff --git a/packages/core/src/background-job.ts b/packages/core/src/background-job.ts new file mode 100644 index 0000000..7bfa6c2 --- /dev/null +++ b/packages/core/src/background-job.ts @@ -0,0 +1,47 @@ +import type { ContextSelection } from './portfolio-context.ts'; + +export type BackgroundJobType = 'research' | 'watchlist-digest' | 'filing-check'; +export type BackgroundJobStatus = 'scheduled' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'missed'; +export type MissedRunPolicy = 'catch-up' | 'skip'; + +export interface BackgroundJob { + id: string; + type: BackgroundJobType; + enabled: boolean; + schedule: { intervalMs: number }; + input: Record; + targetContext?: ContextSelection; + createdAt: number; + nextRunAt: number; + lastRunAt?: number; + lastRunId?: string; + status: BackgroundJobStatus; + retryPolicy: { maxAttempts: number; initialBackoffMs: number; maxBackoffMs: number }; + missedRunPolicy: MissedRunPolicy; + notificationPolicy: { onSuccess: boolean; onFailure: boolean; sensitivePreview: boolean }; +} + +export interface BackgroundJobRun { + id: string; + jobId: string; + status: BackgroundJobStatus; + scheduledFor: number; + startedAt?: number; + finishedAt: number; + attempts: number; + productionRunId?: string; + error?: string; +} + +export interface StoredNotification { + id: string; + jobId: string; + runId: string; + kind: 'completed' | 'failed' | 'digest-ready' | 'filing-found'; + title: string; + message: string; + createdAt: number; + readAt?: number; + /** App route only. Never an arbitrary external URL. */ + deepLink: string; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9e479bb..0424104 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ // Core type definitions for Finagent import type { SupportedLocale } from './locale.ts'; +import type { ContextSelection, ContextSnapshot } from './portfolio-context.ts'; import type { FinancialEvidenceEnvelope } from './financial-evidence.ts'; export type { SupportedLocale, LocalePreference } from './locale.ts'; @@ -405,6 +406,12 @@ export interface WorkspaceContext { selectedPosition?: string; /** Set when the Compare workspace is focused; feeds the compare agent context. */ comparisonSymbols?: string[]; + /** Explicit user-selected persistent contexts for this run. */ + contextSelections?: ContextSelection[]; + /** Frozen main-process snapshots actually supplied to the model. */ + contextSnapshots?: ContextSnapshot[]; + /** Conversation branch scope used to prevent cross-branch context reads. */ + branchId?: string; } export interface AgentRunInput { @@ -577,6 +584,8 @@ export * from './evaluation.ts'; export * from './locale.ts'; export * from './trace.ts'; export * from './trace-projection.ts'; +export * from './portfolio-context.ts'; +export * from './background-job.ts'; export * from './instrument.ts'; export * from './instrument-catalog.ts'; export * from './financial-evidence.ts'; diff --git a/packages/core/src/portfolio-context.ts b/packages/core/src/portfolio-context.ts new file mode 100644 index 0000000..6b9b7d5 --- /dev/null +++ b/packages/core/src/portfolio-context.ts @@ -0,0 +1,64 @@ +/** Explicit, versioned user context. It is never an implicit model profile. */ + +export type ContextKind = 'watchlist' | 'portfolio'; + +export interface WatchlistInstrument { + instrumentId: string; + tags?: string[]; + thesis?: string; + notes?: string; +} + +export interface VersionedWatchlist { + id: string; + name: string; + version: number; + instruments: WatchlistInstrument[]; + createdAt: number; + updatedAt: number; +} + +export interface PortfolioPositionContext { + instrumentId: string; + quantity?: number; + weight?: number; + costBasis?: number; + nativeCurrency: string; + thesis?: string; + notes?: string; +} + +export interface VersionedPortfolioContext { + id: string; + name: string; + version: number; + positions: PortfolioPositionContext[]; + asOf: number; + createdAt: number; + updatedAt: number; +} + +export type ContextDocument = VersionedWatchlist | VersionedPortfolioContext; + +export interface ContextSelection { + kind: ContextKind; + id: string; +} + +export interface ContextSnapshot { + id: string; + kind: ContextKind; + sourceId: string; + sourceVersion: number; + createdAt: number; + /** Frozen, optionally relevance-filtered copy actually supplied to the run. */ + document: ContextDocument; +} + +export interface RunContextBinding { + runId: string; + sessionId: string; + branchId: string; + snapshotIds: string[]; + boundAt: number; +} diff --git a/packages/core/src/research.ts b/packages/core/src/research.ts index b9e54f2..7cee827 100644 --- a/packages/core/src/research.ts +++ b/packages/core/src/research.ts @@ -35,8 +35,17 @@ export interface EvidenceRef { fetchedAt: number; /** Short factual summary of the data point (from CapabilityResult.summary). */ summary?: string; - /** Canonical instrument id linking this evidence to one listing. */ + /** Web source identity, when the evidence came from an online source. */ + sourceId?: string; + sourceUrl?: string; + provider?: string; + /** Structured financial provenance. These fields intentionally do not imply a URL. */ instrumentId?: string; + metric?: string; + asOf?: number; + currency?: string; + unit?: string; + status?: 'available' | 'unavailable' | 'drifted' | 'stale' | 'conflicted'; } /** Condensed outcome of one capability run, embedded in the report. */ @@ -88,6 +97,13 @@ export interface ResearchReport { * failed or were unavailable — the report still stands, gaps are explicit. */ runStatus: ResearchRunStatus; + /** Durable run/config reference used to reproduce or audit the report. */ + runManifest?: { + runId: string; + model?: string; + configVersion?: string; + contextSnapshotIds?: string[]; + }; } /** Lightweight progress record for the Research UI. */ diff --git a/packages/i18n/src/locales/en-US/agent.ts b/packages/i18n/src/locales/en-US/agent.ts index f2d087c..0924cff 100644 --- a/packages/i18n/src/locales/en-US/agent.ts +++ b/packages/i18n/src/locales/en-US/agent.ts @@ -32,6 +32,8 @@ export const agent = { context: { none: 'No security context', clear: 'Clear security context', + useWatchlist: 'Use watchlist', + usePortfolio: 'Use portfolio', }, model: { label: 'Model', diff --git a/packages/i18n/src/locales/en-US/research.ts b/packages/i18n/src/locales/en-US/research.ts index 92fd9f1..604921e 100644 --- a/packages/i18n/src/locales/en-US/research.ts +++ b/packages/i18n/src/locales/en-US/research.ts @@ -245,6 +245,8 @@ export const research = { export: { copyMarkdown: 'Copy Markdown', downloadMarkdown: 'Download .md', + downloadHtml: 'Download printable HTML', + downloadJson: 'Download archive JSON', copyShareText: 'Copy share text', downloadShareCard: 'Download share card .svg', working: 'Working…', diff --git a/packages/i18n/src/locales/zh-CN/agent.ts b/packages/i18n/src/locales/zh-CN/agent.ts index 903625b..302caaf 100644 --- a/packages/i18n/src/locales/zh-CN/agent.ts +++ b/packages/i18n/src/locales/zh-CN/agent.ts @@ -32,6 +32,8 @@ export const agent = { context: { none: '暂无证券上下文', clear: '清除证券上下文', + useWatchlist: '使用关注列表', + usePortfolio: '使用投资组合', }, model: { label: '模型', diff --git a/packages/i18n/src/locales/zh-CN/research.ts b/packages/i18n/src/locales/zh-CN/research.ts index ce0ee78..e692536 100644 --- a/packages/i18n/src/locales/zh-CN/research.ts +++ b/packages/i18n/src/locales/zh-CN/research.ts @@ -232,6 +232,8 @@ export const research = { export: { copyMarkdown: '复制 Markdown', downloadMarkdown: '下载 .md', + downloadHtml: '下载可打印 HTML', + downloadJson: '下载归档 JSON', copyShareText: '复制分享文案', downloadShareCard: '下载分享卡片 .svg', working: '处理中…', diff --git a/packages/shared/src/agent/pi-runtime-adapter.ts b/packages/shared/src/agent/pi-runtime-adapter.ts index 3f6d52d..facede1 100644 --- a/packages/shared/src/agent/pi-runtime-adapter.ts +++ b/packages/shared/src/agent/pi-runtime-adapter.ts @@ -490,6 +490,14 @@ function buildPrompt( if (workspaceContext?.selectedPosition) { workspaceLines.push(`- Selected position: ${workspaceContext.selectedPosition}`); } + if (workspaceContext?.contextSnapshots?.length) { + workspaceLines.push('- Explicit versioned context snapshots (frozen for this run):'); + for (const snapshot of workspaceContext.contextSnapshots) { + workspaceLines.push(` - ${snapshot.kind}:${snapshot.sourceId}@v${snapshot.sourceVersion} snapshot=${snapshot.id}`); + workspaceLines.push(` ${JSON.stringify(snapshot.document)}`); + } + workspaceLines.push('- Treat portfolio/watchlist fields as user-provided context, not external evidence. Verify factual claims with normal finance tools and citations.'); + } const workspaceSection = workspaceLines.length > 0 ? `\nWorkspace context:\n${workspaceLines.join('\n')}\nWhen the user refers to "this", "the stock", or asks follow-up questions about a symbol without naming it, use the active symbol above.` : ''; diff --git a/packages/shared/src/agent/workspace-context.test.ts b/packages/shared/src/agent/workspace-context.test.ts index 03fd915..a51602a 100644 --- a/packages/shared/src/agent/workspace-context.test.ts +++ b/packages/shared/src/agent/workspace-context.test.ts @@ -104,6 +104,24 @@ describe('WorkspaceContext → prompt', () => { expect(events).toContain('run_completed'); }); + it('injects only the explicit frozen context snapshot and labels it non-evidence', async () => { + const proc = new FakePiProcess(); + const client = new PiRpcClient({ spawnProcess: createSpawn(proc) }); + const adapter = new PiRuntimeAdapter({ rpcClient: client, sessionDir: '/tmp/ws-test' }); + for await (const _event of adapter.run({ + sessionId: 's-context', runId: 'r-context', content: 'review my holdings', + workspaceContext: { branchId: 'main', contextSnapshots: [{ + id: 'ctx-1', kind: 'portfolio', sourceId: 'portfolio-1', sourceVersion: 3, createdAt: 100, + document: { id: 'portfolio-1', name: 'Core', version: 3, asOf: 90, createdAt: 1, updatedAt: 90, positions: [{ instrumentId: 'AAPL.US', quantity: 10, nativeCurrency: 'USD' }] }, + }] }, + })) {} + const prompt = proc.received.find((line) => line.type === 'prompt'); + const message = String(prompt?.message ?? ''); + expect(message).toContain('portfolio:portfolio-1@v3 snapshot=ctx-1'); + expect(message).toContain('"instrumentId":"AAPL.US"'); + expect(message).toContain('not external evidence'); + }); + it('omits the workspace section when no context is provided', async () => { const proc = new FakePiProcess(); const client = new PiRpcClient({ spawnProcess: createSpawn(proc) }); diff --git a/packages/shared/src/automation/index.ts b/packages/shared/src/automation/index.ts index 43b64e4..a0794da 100644 --- a/packages/shared/src/automation/index.ts +++ b/packages/shared/src/automation/index.ts @@ -33,3 +33,8 @@ export { formatAutomationNotification, type AutomationNotification, } from './notifications.ts' +export { + BackgroundJobRepository, + BackgroundJobScheduler, + type BackgroundJobExecutor, +} from './job-engine.ts' diff --git a/packages/shared/src/automation/job-engine.test.ts b/packages/shared/src/automation/job-engine.test.ts new file mode 100644 index 0000000..e01c14f --- /dev/null +++ b/packages/shared/src/automation/job-engine.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { BackgroundJob } from '@finagent/core'; +import { JsonFileStore } from '../storage/json-file-store.ts'; +import { BackgroundJobRepository, BackgroundJobScheduler } from './job-engine.ts'; + +const now = 1_800_000_000_000; +function job(overrides: Partial = {}): BackgroundJob { + return { id: 'filings-aapl', type: 'filing-check', enabled: true, schedule: { intervalMs: 60_000 }, input: { symbol: 'AAPL.US' }, createdAt: now - 60_000, nextRunAt: now - 1_000, status: 'scheduled', retryPolicy: { maxAttempts: 2, initialBackoffMs: 1, maxBackoffMs: 2 }, missedRunPolicy: 'catch-up', notificationPolicy: { onSuccess: true, onFailure: true, sensitivePreview: false }, ...overrides }; +} +async function setup() { const dir = await mkdtemp(join(tmpdir(), 'folio-jobs-')); return { dir, repo: new BackgroundJobRepository(new JsonFileStore(dir)) }; } + +describe('persistent background jobs', () => { + it('restores schedules and last-run state after restart', async () => { + const { dir, repo } = await setup(); + await repo.saveJob(job()); + const scheduler = new BackgroundJobScheduler(repo, { run: async () => ({ productionRunId: 'research-1' }) }, { now: () => now }); + await scheduler.tick(); + const restarted = new BackgroundJobRepository(new JsonFileStore(dir)); + expect((await restarted.listJobs())[0]).toMatchObject({ status: 'succeeded', lastRunId: expect.any(String) }); + expect((await restarted.listRuns())[0]).toMatchObject({ status: 'succeeded', productionRunId: 'research-1' }); + expect((await restarted.listNotifications())[0]?.deepLink).toBe('/runs/research-1'); + }); + + it('prevents duplicate concurrent claims', async () => { + const { repo } = await setup(); + await repo.saveJob(job()); + const claims = await Promise.all([repo.claimDue('filings-aapl', now), repo.claimDue('filings-aapl', now)]); + expect(claims.filter(Boolean)).toHaveLength(1); + }); + + it('keeps a running claim leased beyond the research deadline', async () => { + const { repo } = await setup(); + await repo.saveJob(job()); + expect(await repo.claimDue('filings-aapl', now)).toBeDefined(); + expect(await repo.claimDue('filings-aapl', now + 30 * 60_000)).toBeUndefined(); + }); + + it('records a missed occurrence when the policy is skip', async () => { + const { repo } = await setup(); + await repo.saveJob(job({ nextRunAt: now - 120_000, missedRunPolicy: 'skip' })); + let executions = 0; + await new BackgroundJobScheduler(repo, { run: async () => { executions += 1; return {}; } }, { now: () => now }).tick(); + expect(executions).toBe(0); + expect((await repo.listRuns())[0]?.status).toBe('missed'); + }); + + it('retries within bounds and persists a privacy-safe failure notification', async () => { + const { repo } = await setup(); + await repo.saveJob(job()); + let attempts = 0; + await new BackgroundJobScheduler(repo, { run: async () => { attempts += 1; throw new Error('private portfolio AAPL quantity 99'); } }, { now: () => now, wait: async () => undefined }).tick(); + expect(attempts).toBe(2); + expect((await repo.listRuns())[0]).toMatchObject({ status: 'failed', attempts: 2 }); + const notification = (await repo.listNotifications())[0]; + expect(notification?.kind).toBe('failed'); + expect(notification?.message).toBe('Open Folio for details.'); + expect(notification?.message).not.toContain('AAPL'); + }); + + it('redacts credential-shaped failure previews even when requested', async () => { + const { repo } = await setup(); + await repo.saveJob(job({ notificationPolicy: { onSuccess: true, onFailure: true, sensitivePreview: true } })); + await new BackgroundJobScheduler(repo, { run: async () => { throw new Error('GET https://api.example.test?q=1&api_key=sk-secret-12345678'); } }, { now: () => now, wait: async () => undefined }).tick(); + const notification = (await repo.listNotifications())[0]; + expect(notification?.message).not.toContain('sk-secret-12345678'); + expect(notification?.message).toContain(''); + }); + + it('can disable and delete a recurring job', async () => { + const { repo } = await setup(); + await repo.saveJob(job()); + expect((await repo.setEnabled('filings-aapl', false))?.enabled).toBe(false); + await repo.removeJob('filings-aapl'); + expect(await repo.listJobs()).toEqual([]); + }); + + it('rejects invalid intervals and safely ignores unknown or future jobs', async () => { + const { repo } = await setup(); + expect(repo.saveJob(job({ schedule: { intervalMs: 0 } }))).rejects.toThrow('interval must be positive'); + expect(await repo.setEnabled('missing', false)).toBeUndefined(); + await repo.saveJob(job({ nextRunAt: now + 60_000 })); + expect(await repo.claimDue('filings-aapl', now)).toBeUndefined(); + expect(await repo.claimDue('missing', now)).toBeUndefined(); + }); + + it('emits a digest notification through the callback after persistence', async () => { + const { repo } = await setup(); + await repo.saveJob(job({ type: 'watchlist-digest', notificationPolicy: { onSuccess: true, onFailure: true, sensitivePreview: true } })); + const delivered: string[] = []; + await new BackgroundJobScheduler(repo, { run: async () => ({ notificationKind: 'digest-ready' }) }, { + now: () => now, + notify: async (notification) => { delivered.push(notification.kind); expect((await repo.listNotifications())[0]?.id).toBe(notification.id); }, + }).tick(); + expect(delivered).toEqual(['digest-ready']); + expect((await repo.listNotifications())[0]).toMatchObject({ kind: 'digest-ready', message: 'watchlist-digest is ready' }); + }); +}); diff --git a/packages/shared/src/automation/job-engine.ts b/packages/shared/src/automation/job-engine.ts new file mode 100644 index 0000000..d537534 --- /dev/null +++ b/packages/shared/src/automation/job-engine.ts @@ -0,0 +1,181 @@ +import { randomUUID } from 'node:crypto'; +import type { BackgroundJob, BackgroundJobRun, StoredNotification } from '@finagent/core'; +import type { JsonFileStore } from '../storage/json-file-store.ts'; + +interface JobFile { + jobs: BackgroundJob[]; + runs: BackgroundJobRun[]; + notifications: StoredNotification[]; + claims: Record; +} + +const EMPTY: JobFile = { jobs: [], runs: [], notifications: [], claims: {} }; + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** Atomic-in-process persistence for scheduler state and its notification center. */ +export class BackgroundJobRepository { + private static readonly FILE = 'automation/background-jobs.json'; + private queue: Promise = Promise.resolve(); + + constructor(private readonly store: JsonFileStore) {} + + private read(): Promise { + return this.store.read(BackgroundJobRepository.FILE, clone(EMPTY)); + } + + private mutate(operation: (file: JobFile) => Promise | T): Promise { + const previous = this.queue; + let release = (): void => undefined; + this.queue = new Promise((resolve) => { release = resolve; }); + return previous.then(async () => { + try { + const file = await this.read(); + const result = await operation(file); + await this.store.write(BackgroundJobRepository.FILE, file); + return result; + } finally { + release(); + } + }); + } + + async listJobs(): Promise { return (await this.read()).jobs; } + async listRuns(jobId?: string): Promise { + const runs = (await this.read()).runs; + return jobId ? runs.filter((run) => run.jobId === jobId) : runs; + } + async listNotifications(): Promise { return (await this.read()).notifications; } + + saveJob(job: BackgroundJob): Promise { + return this.mutate((file) => { + if (!Number.isFinite(job.schedule.intervalMs) || job.schedule.intervalMs <= 0) throw new Error('Job interval must be positive'); + file.jobs = [clone(job), ...file.jobs.filter((item) => item.id !== job.id)]; + return job; + }); + } + + removeJob(jobId: string): Promise { + return this.mutate((file) => { file.jobs = file.jobs.filter((job) => job.id !== jobId); delete file.claims[jobId]; }); + } + + setEnabled(jobId: string, enabled: boolean): Promise { + return this.mutate((file) => { + const job = file.jobs.find((item) => item.id === jobId); + if (!job) return undefined; + job.enabled = enabled; + return clone(job); + }); + } + + /** Claim is serialized so concurrent ticks cannot launch the same occurrence twice. */ + /** The lease must outlive the Electron research deadline to avoid duplicate work. */ + claimDue(jobId: string, now: number, leaseMs = 45 * 60_000): Promise<{ job: BackgroundJob; runId: string } | undefined> { + return this.mutate((file) => { + const job = file.jobs.find((item) => item.id === jobId); + const current = file.claims[jobId]; + if (!job?.enabled || job.nextRunAt > now || (current && current.leaseUntil > now)) return undefined; + const runId = randomUUID(); + file.claims[jobId] = { runId, leaseUntil: now + leaseMs }; + job.status = 'running'; + return { job: clone(job), runId }; + }); + } + + finish(job: BackgroundJob, run: BackgroundJobRun, notification?: StoredNotification): Promise { + return this.mutate((file) => { + const stored = file.jobs.find((item) => item.id === job.id); + if (!stored) return; + stored.status = run.status; + stored.lastRunAt = run.finishedAt; + stored.lastRunId = run.id; + stored.nextRunAt = Math.max(job.nextRunAt + job.schedule.intervalMs, run.finishedAt + job.schedule.intervalMs); + delete file.claims[job.id]; + file.runs = [run, ...file.runs.filter((item) => item.id !== run.id)]; + if (notification) file.notifications = [notification, ...file.notifications]; + }); + } + + recordMissed(job: BackgroundJob, now: number): Promise { + const run: BackgroundJobRun = { id: randomUUID(), jobId: job.id, status: 'missed', scheduledFor: job.nextRunAt, finishedAt: now, attempts: 0 }; + return this.finish(job, run); + } +} + +export interface BackgroundJobExecutor { + run(job: BackgroundJob): Promise<{ productionRunId?: string; notificationKind?: StoredNotification['kind'] }>; +} + +export class BackgroundJobScheduler { + private readonly wait: (ms: number) => Promise; + + constructor( + private readonly repository: BackgroundJobRepository, + private readonly executor: BackgroundJobExecutor, + private readonly options: { now?: () => number; wait?: (ms: number) => Promise; notify?: (notification: StoredNotification) => void | Promise } = {} + ) { + this.wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + } + + async tick(): Promise { + const now = this.options.now?.() ?? Date.now(); + for (const job of await this.repository.listJobs()) { + if (!job.enabled || job.nextRunAt > now) continue; + if (job.missedRunPolicy === 'skip' && now - job.nextRunAt >= job.schedule.intervalMs) { + await this.repository.recordMissed(job, now); + continue; + } + const claim = await this.repository.claimDue(job.id, now); + if (!claim) continue; + await this.execute(claim.job, claim.runId, now); + } + } + + private async execute(job: BackgroundJob, runId: string, startedAt: number): Promise { + let error: unknown; + for (let attempt = 1; attempt <= job.retryPolicy.maxAttempts; attempt += 1) { + try { + const result = await this.executor.run(job); + const finishedAt = this.options.now?.() ?? Date.now(); + const run: BackgroundJobRun = { id: runId, jobId: job.id, status: 'succeeded', scheduledFor: job.nextRunAt, startedAt, finishedAt, attempts: attempt, productionRunId: result.productionRunId }; + const notification = job.notificationPolicy.onSuccess + ? this.notification(job, run, result.notificationKind ?? (job.type === 'filing-check' ? 'filing-found' : 'completed')) : undefined; + await this.repository.finish(job, run, notification); + if (notification) await this.options.notify?.(notification); + return; + } catch (caught) { + error = caught; + if (attempt < job.retryPolicy.maxAttempts) { + const backoff = Math.min(job.retryPolicy.initialBackoffMs * 2 ** (attempt - 1), job.retryPolicy.maxBackoffMs); + await this.wait(backoff); + } + } + } + const finishedAt = this.options.now?.() ?? Date.now(); + const run: BackgroundJobRun = { id: runId, jobId: job.id, status: 'failed', scheduledFor: job.nextRunAt, startedAt, finishedAt, attempts: job.retryPolicy.maxAttempts, error: safeErrorPreview(error) }; + const notification = job.notificationPolicy.onFailure ? this.notification(job, run, 'failed') : undefined; + await this.repository.finish(job, run, notification); + if (notification) await this.options.notify?.(notification); + } + + private notification(job: BackgroundJob, run: BackgroundJobRun, kind: StoredNotification['kind']): StoredNotification { + const failed = kind === 'failed'; + return { + id: `notification-${run.id}`, jobId: job.id, runId: run.id, kind, + title: failed ? 'Research needs attention' : job.type === 'filing-check' ? 'New filing found' : 'Research complete', + message: job.notificationPolicy.sensitivePreview ? (failed ? run.error ?? 'Task failed' : `${job.type} is ready`) : (failed ? 'Open Folio for details.' : 'Open Folio to view the result.'), + createdAt: run.finishedAt, + deepLink: run.productionRunId ? `/runs/${encodeURIComponent(run.productionRunId)}` : `/jobs/${encodeURIComponent(job.id)}`, + }; + } +} + +function safeErrorPreview(error: unknown): string { + const message = error instanceof Error ? error.message : 'Background task failed'; + return message + .replace(/([?&](?:api[-_]?key|access[-_]?token|refresh[-_]?token|authorization|password|secret)=)[^&#\s]+/gi, '$1') + .replace(/\b(?:sk|api|token|canary)[-_][A-Za-z0-9_-]{8,}\b/g, '') + .slice(0, 500); +} diff --git a/packages/shared/src/context/index.ts b/packages/shared/src/context/index.ts new file mode 100644 index 0000000..ec88eb4 --- /dev/null +++ b/packages/shared/src/context/index.ts @@ -0,0 +1 @@ +export { PortfolioContextRepository } from './repository.ts'; diff --git a/packages/shared/src/context/repository.test.ts b/packages/shared/src/context/repository.test.ts new file mode 100644 index 0000000..b7950ae --- /dev/null +++ b/packages/shared/src/context/repository.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { JsonFileStore } from '../storage/json-file-store.ts'; +import { PortfolioContextRepository } from './repository.ts'; + +async function repository(now = 1_800_000_000_000) { + const dir = await mkdtemp(join(tmpdir(), 'folio-context-')); + return { dir, repo: new PortfolioContextRepository(new JsonFileStore(dir), () => now) }; +} + +describe('PortfolioContextRepository', () => { + it('persists structured watchlists and portfolios with canonical ids and versions', async () => { + const { dir, repo } = await repository(); + const instruments = ['AAPL.US', 'MSFT.US', 'NVDA.US', '0700.HK', 'D05.SG']; + const watchlist = await repo.saveWatchlist({ id: 'wl-main', name: 'AI leaders', instruments: instruments.map((instrumentId) => ({ instrumentId })) }); + const portfolio = await repo.savePortfolio({ id: 'pf-main', name: 'Core', asOf: 1_799_000_000_000, positions: instruments.map((instrumentId) => ({ instrumentId, quantity: 10, nativeCurrency: instrumentId.endsWith('.HK') ? 'HKD' : 'USD' })) }); + expect(watchlist.version).toBe(1); + expect(portfolio.positions).toHaveLength(5); + const restarted = new PortfolioContextRepository(new JsonFileStore(dir)); + expect((await restarted.listWatchlists())[0]?.instruments).toHaveLength(5); + }); + + it('freezes a minimal run snapshot and does not rewrite history after edits', async () => { + const { repo } = await repository(); + await repo.savePortfolio({ id: 'pf', name: 'Core', asOf: 100, positions: [ + { instrumentId: 'AAPL.US', quantity: 10, nativeCurrency: 'USD' }, + { instrumentId: 'MSFT.US', quantity: 20, nativeCurrency: 'USD' }, + ] }); + const first = await repo.bindRun({ runId: 'run-1', sessionId: 'session-1', branchId: 'main', selections: [{ kind: 'portfolio', id: 'pf' }], relevantInstrumentIds: ['AAPL.US'] }); + await repo.savePortfolio({ id: 'pf', name: 'Core', asOf: 200, positions: [{ instrumentId: 'AAPL.US', quantity: 99, nativeCurrency: 'USD' }] }); + const historical = await repo.getRunContext('run-1', 'session-1', 'main'); + expect(first.snapshots[0]?.sourceVersion).toBe(1); + expect((historical?.snapshots[0]?.document as { positions: Array<{ instrumentId: string; quantity?: number; nativeCurrency: string }> }).positions).toEqual([{ instrumentId: 'AAPL.US', quantity: 10, nativeCurrency: 'USD' }]); + expect((await repo.listPortfolios())[0]?.version).toBe(2); + }); + + it('does not leak a binding across conversations or branches', async () => { + const { repo } = await repository(); + await repo.saveWatchlist({ id: 'wl', name: 'Private', instruments: [{ instrumentId: 'NVDA.US' }] }); + await repo.bindRun({ runId: 'run-private', sessionId: 'session-a', branchId: 'branch-a', selections: [{ kind: 'watchlist', id: 'wl' }] }); + expect(await repo.getRunContext('run-private', 'session-b', 'branch-a')).toBeUndefined(); + expect(await repo.getRunContext('run-private', 'session-a', 'branch-b')).toBeUndefined(); + expect(await repo.getRunContext('run-private', 'session-a', 'branch-a')).toBeDefined(); + }); + + it('rejects bare ticker strings', async () => { + const { repo } = await repository(); + expect(repo.saveWatchlist({ id: 'wl', name: 'Bad', instruments: [{ instrumentId: 'AAPL' }] })).rejects.toThrow('Invalid canonical instrument id'); + }); + + it('fails clearly when an explicitly selected context does not exist', async () => { + const { repo } = await repository(); + expect(repo.snapshot({ kind: 'watchlist', id: 'missing' })).rejects.toThrow('watchlist context not found'); + expect(await repo.get({ kind: 'portfolio', id: 'missing' })).toBeUndefined(); + }); + + it('binds an existing frozen snapshot without copying unrelated context', async () => { + const { repo } = await repository(); + await repo.saveWatchlist({ id: 'wl', name: 'Focused', instruments: [{ instrumentId: 'AAPL.US' }, { instrumentId: 'MSFT.US' }] }); + const snapshot = await repo.snapshot({ kind: 'watchlist', id: 'wl' }, ['MSFT.US']); + await repo.bindSnapshots({ runId: 'run-frozen', sessionId: 'session-a', branchId: 'main', snapshots: [snapshot] }); + const result = await repo.getRunContext('run-frozen', 'session-a', 'main'); + expect((result?.snapshots[0]?.document as { instruments: Array<{ instrumentId: string }> }).instruments).toEqual([{ instrumentId: 'MSFT.US' }]); + }); + + it('gives same-millisecond snapshots unique durable ids', async () => { + const { repo } = await repository(); + await repo.saveWatchlist({ id: 'wl', name: 'Focused', instruments: [{ instrumentId: 'AAPL.US' }] }); + const [first, second] = await Promise.all([ + repo.snapshot({ kind: 'watchlist', id: 'wl' }), + repo.snapshot({ kind: 'watchlist', id: 'wl' }), + ]); + expect(first.id).not.toBe(second.id); + }); + + it('serializes concurrent edits so versions are monotonic and no update is lost', async () => { + const { repo } = await repository(); + await Promise.all([ + repo.saveWatchlist({ id: 'wl', name: 'One', instruments: [{ instrumentId: 'AAPL.US' }] }), + repo.saveWatchlist({ id: 'wl', name: 'Two', instruments: [{ instrumentId: 'MSFT.US' }] }), + ]); + const saved = (await repo.listWatchlists())[0]; + expect(saved?.version).toBe(2); + expect(['One', 'Two']).toContain(saved?.name); + }); +}); diff --git a/packages/shared/src/context/repository.ts b/packages/shared/src/context/repository.ts new file mode 100644 index 0000000..697ff6e --- /dev/null +++ b/packages/shared/src/context/repository.ts @@ -0,0 +1,154 @@ +import { randomUUID } from 'node:crypto'; +import type { + ContextDocument, + ContextSelection, + ContextSnapshot, + RunContextBinding, + VersionedPortfolioContext, + VersionedWatchlist, +} from '@finagent/core'; +import type { JsonFileStore } from '../storage/json-file-store.ts'; + +const INSTRUMENT_ID = /^[A-Z0-9]{1,5}\.(US|HK|SG|SH|SZ|HAS)$/; + +interface ContextFile { + watchlists: VersionedWatchlist[]; + portfolios: VersionedPortfolioContext[]; + snapshots: ContextSnapshot[]; + bindings: RunContextBinding[]; +} + +const EMPTY: ContextFile = { watchlists: [], portfolios: [], snapshots: [], bindings: [] }; + +function validateInstruments(ids: string[]): void { + const invalid = ids.find((id) => !INSTRUMENT_ID.test(id)); + if (invalid) throw new Error(`Invalid canonical instrument id: ${invalid}`); +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export class PortfolioContextRepository { + private static readonly FILE = 'context/versioned-context.json'; + private queue: Promise = Promise.resolve(); + constructor(private readonly store: JsonFileStore, private readonly now: () => number = Date.now) {} + + private read(): Promise { + return this.store.read(PortfolioContextRepository.FILE, clone(EMPTY)); + } + + private write(file: ContextFile): Promise { + return this.store.write(PortfolioContextRepository.FILE, file); + } + + private mutate(operation: (file: ContextFile) => Promise | T): Promise { + const previous = this.queue; + let release = (): void => undefined; + this.queue = new Promise((resolve) => { release = resolve; }); + return previous.then(async () => { + try { + const file = await this.read(); + const result = await operation(file); + await this.write(file); + return result; + } finally { + release(); + } + }); + } + + async listWatchlists(): Promise { + return (await this.read()).watchlists; + } + + async listPortfolios(): Promise { + return (await this.read()).portfolios; + } + + async saveWatchlist(input: Omit): Promise { + validateInstruments(input.instruments.map((item) => item.instrumentId)); + return this.mutate((file) => { + const previous = file.watchlists.find((item) => item.id === input.id); + const at = this.now(); + const next: VersionedWatchlist = { ...clone(input), version: (previous?.version ?? 0) + 1, createdAt: previous?.createdAt ?? at, updatedAt: at }; + file.watchlists = [next, ...file.watchlists.filter((item) => item.id !== input.id)]; + return next; + }); + } + + async savePortfolio(input: Omit): Promise { + validateInstruments(input.positions.map((item) => item.instrumentId)); + return this.mutate((file) => { + const previous = file.portfolios.find((item) => item.id === input.id); + const at = this.now(); + const next: VersionedPortfolioContext = { ...clone(input), version: (previous?.version ?? 0) + 1, createdAt: previous?.createdAt ?? at, updatedAt: at }; + file.portfolios = [next, ...file.portfolios.filter((item) => item.id !== input.id)]; + return next; + }); + } + + private async find(selection: ContextSelection): Promise { + const file = await this.read(); + return selection.kind === 'watchlist' + ? file.watchlists.find((item) => item.id === selection.id) + : file.portfolios.find((item) => item.id === selection.id); + } + + /** Resolve the latest saved document for an explicitly named context. */ + async get(selection: ContextSelection): Promise { + const document = await this.find(selection); + return document ? clone(document) : undefined; + } + + async snapshot(selection: ContextSelection, relevantInstrumentIds?: string[]): Promise { + return this.mutate((file) => { + const snapshot = this.createSnapshot(file, selection, relevantInstrumentIds); + file.snapshots.push(snapshot); + return snapshot; + }); + } + + private createSnapshot(file: ContextFile, selection: ContextSelection, relevantInstrumentIds?: string[]): ContextSnapshot { + const source = selection.kind === 'watchlist' ? file.watchlists.find((item) => item.id === selection.id) : file.portfolios.find((item) => item.id === selection.id); + if (!source) throw new Error(`${selection.kind} context not found: ${selection.id}`); + const allowed = relevantInstrumentIds ? new Set(relevantInstrumentIds) : undefined; + const document: ContextDocument = selection.kind === 'watchlist' + ? { ...(source as VersionedWatchlist), instruments: (source as VersionedWatchlist).instruments.filter((item) => !allowed || allowed.has(item.instrumentId)) } + : { ...(source as VersionedPortfolioContext), positions: (source as VersionedPortfolioContext).positions.filter((item) => !allowed || allowed.has(item.instrumentId)) }; + return { id: `ctx-${selection.kind}-${source.id}-v${source.version}-${this.now()}-${randomUUID()}`, kind: selection.kind, sourceId: source.id, sourceVersion: source.version, createdAt: this.now(), document: clone(document) }; + } + + async bindRun(input: { + runId: string; + sessionId: string; + branchId: string; + selections: ContextSelection[]; + relevantInstrumentIds?: string[]; + }): Promise<{ binding: RunContextBinding; snapshots: ContextSnapshot[] }> { + return this.mutate((file) => { + const snapshots = input.selections.map((selection) => this.createSnapshot(file, selection, input.relevantInstrumentIds)); + file.snapshots.push(...snapshots); + const binding: RunContextBinding = { runId: input.runId, sessionId: input.sessionId, branchId: input.branchId, snapshotIds: snapshots.map((snapshot) => snapshot.id), boundAt: this.now() }; + file.bindings = [binding, ...file.bindings.filter((item) => item.runId !== input.runId)]; + return { binding, snapshots }; + }); + } + + async bindSnapshots(input: { runId: string; sessionId: string; branchId: string; snapshots: ContextSnapshot[] }): Promise { + return this.mutate((file) => { + const binding: RunContextBinding = { runId: input.runId, sessionId: input.sessionId, branchId: input.branchId, snapshotIds: input.snapshots.map((snapshot) => snapshot.id), boundAt: this.now() }; + file.bindings = [binding, ...file.bindings.filter((item) => item.runId !== input.runId)]; + return binding; + }); + } + + /** Scope is mandatory: another conversation/branch cannot read this binding by run id alone. */ + async getRunContext(runId: string, sessionId: string, branchId: string): Promise<{ binding: RunContextBinding; snapshots: ContextSnapshot[] } | undefined> { + const file = await this.read(); + const binding = file.bindings.find((item) => item.runId === runId && item.sessionId === sessionId && item.branchId === branchId); + if (!binding) return undefined; + const ids = new Set(binding.snapshotIds); + return { binding, snapshots: file.snapshots.filter((snapshot) => ids.has(snapshot.id)).map(clone) }; + } +} diff --git a/packages/shared/src/export/artifact.test.ts b/packages/shared/src/export/artifact.test.ts new file mode 100644 index 0000000..390b9bd --- /dev/null +++ b/packages/shared/src/export/artifact.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'bun:test'; +import { reportToArchive, reportToHtml, reportToJson } from './artifact.ts'; +import { redactForShare } from './privacy.ts'; +import { reportFixture } from './test-helpers.ts'; + +describe('research report artifact export', () => { + const report = reportFixture({ + runManifest: { runId: 'research-run-1', model: 'test-model', configVersion: 'cfg-v1' }, + sections: [{ + key: 'financials', title: 'Financials', verdict: 'positive', summary: 'Revenue grew 18%.', + evidence: [{ + capabilityId: 'company.financials', runId: 'cap-run-1', claim: 'Revenue grew 18%.', + fetchedAt: 1_784_000_000_000, instrumentId: 'AAPL.US', metric: 'revenue_growth', + asOf: 1_783_000_000_000, currency: 'USD', unit: 'percent', status: 'available', + }, { + capabilityId: 'research.news', runId: 'cap-run-2', claim: 'Management raised guidance.', + fetchedAt: 1_784_000_000_000, sourceId: 'src-1', sourceUrl: 'https://example.com/news', provider: 'Example News', + }], + }], + }); + + it('exports a machine-readable report → claim → evidence → run chain', () => { + const archive = reportToArchive(report); + expect(archive.schemaVersion).toBe('folio-report-export-v1'); + expect(archive.claims[0]?.citationNumbers).toEqual([1, 2]); + expect(archive.citations[0]?.financial).toMatchObject({ instrumentId: 'AAPL.US', currency: 'USD', unit: 'percent' }); + expect(JSON.parse(reportToJson(report)).runManifest.runId).toBe('research-run-1'); + }); + + it('renders self-contained print-ready HTML with stable citations and both evidence kinds', () => { + const html = reportToHtml(report); + expect(html).toContain('href="#citation-1"'); + expect(html).toContain('https://example.com/news'); + expect(html).toContain('AAPL.US · revenue_growth · USD · percent'); + expect(html).toContain('@media print'); + }); + + it('preserves Markdown tables as semantic HTML tables', () => { + const html = reportToHtml(reportFixture({ + sections: [{ key: 'metrics', title: 'Metrics', verdict: 'neutral', summary: '| Metric | Value |\n|---|---:|\n| Revenue | $39.3B |', evidence: [] }], + })); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).not.toContain('|---|'); + }); + + it('redacts secret keys and canary values from every export format', () => { + const unsafe = reportFixture({ + summary: 'token canary-export-secret-123456 must not escape', + runManifest: { runId: 'run-1', configVersion: 'v1', authorization: 'Bearer secret' } as never, + }); + const safe = redactForShare(unsafe); + const json = reportToJson(safe); + const html = reportToHtml(safe); + for (const output of [json, html]) { + expect(output).not.toContain('canary-export-secret-123456'); + expect(output).not.toContain('Bearer secret'); + } + expect(json).toContain(''); + expect(html).toContain('<REDACTED>'); + }); + + it('deduplicates repeated evidence and escapes report-controlled HTML', () => { + const evidence = { capabilityId: 'research.news', runId: 'run-1', claim: '', fetchedAt: 1, sourceUrl: 'https://example.com/?a=1&b=2' }; + const unsafe = reportFixture({ + summary: '', + sections: [ + { key: 'a', title: 'A', verdict: 'neutral', summary: 'One', evidence: [evidence] }, + { key: 'b', title: 'B', verdict: 'neutral', summary: 'Two', evidence: [evidence] }, + ], + }); + const archive = reportToArchive(unsafe); + const html = reportToHtml(unsafe); + expect(archive.citations).toHaveLength(1); + expect(archive.claims.map((claim) => claim.citationNumbers)).toEqual([[1], [1]]); + expect(html).not.toContain('
Metric$39.3B