From 5861ec676436def90a1048f94773f2f022f173be Mon Sep 17 00:00:00 2001 From: Cordis798 <284918518+Cordis798@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:03:17 +0800 Subject: [PATCH 1/2] feat: close research platform issues 36-38 --- apps/electron/src/main/index.ts | 20 ++ apps/electron/src/main/kernelHost.test.ts | 36 ++- apps/electron/src/main/kernelHost.ts | 243 ++++++++++++++++- apps/electron/src/preload/index.cjs | 21 ++ apps/electron/src/preload/index.ts | 38 +++ artifacts/issues-36-38-e2e/e2e-evidence.json | 204 ++++++++++++++ .../issues-36-38-e2e/nvda-fy2025-report.html | 1 + .../issues-36-38-e2e/nvda-fy2025-report.json | 249 ++++++++++++++++++ .../issues-36-38-e2e/nvda-fy2025-report.md | 46 ++++ .../issues-36-38-e2e/nvda-fy2025-report.png | Bin 0 -> 91969 bytes package.json | 3 +- packages/core/src/background-job.ts | 47 ++++ packages/core/src/index.ts | 9 + packages/core/src/portfolio-context.ts | 64 +++++ packages/core/src/research.ts | 18 ++ packages/i18n/src/locales/en-US/agent.ts | 2 + packages/i18n/src/locales/en-US/research.ts | 2 + packages/i18n/src/locales/zh-CN/agent.ts | 2 + packages/i18n/src/locales/zh-CN/research.ts | 2 + .../shared/src/agent/pi-runtime-adapter.ts | 8 + .../agent/pi-runtime-agent-backend.test.ts | 2 +- .../src/agent/workspace-context.test.ts | 18 ++ packages/shared/src/automation/index.ts | 5 + .../shared/src/automation/job-engine.test.ts | 100 +++++++ packages/shared/src/automation/job-engine.ts | 181 +++++++++++++ packages/shared/src/context/index.ts | 1 + .../shared/src/context/repository.test.ts | 88 +++++++ packages/shared/src/context/repository.ts | 154 +++++++++++ packages/shared/src/export/artifact.test.ts | 99 +++++++ packages/shared/src/export/artifact.ts | 177 +++++++++++++ packages/shared/src/export/index.ts | 9 + packages/shared/src/export/privacy.ts | 15 +- packages/shared/src/index.ts | 1 + packages/shared/src/research/runner.test.ts | 4 + packages/shared/src/research/runner.ts | 25 +- packages/shared/src/research/test-helpers.ts | 5 + packages/ui/src/atoms/contextAtoms.test.ts | 30 +++ packages/ui/src/atoms/contextAtoms.ts | 54 ++++ packages/ui/src/atoms/exportAtoms.ts | 22 ++ packages/ui/src/atoms/index.ts | 3 +- .../ui/src/components/agent/AgentPanel.tsx | 43 ++- .../ui/src/components/agent/ContextChip.tsx | 12 + .../components/research/ExportMenu.test.tsx | 27 +- .../ui/src/components/research/ExportMenu.tsx | 14 +- scripts/eval/issues-36-38-e2e.ts | 127 +++++++++ 45 files changed, 2192 insertions(+), 39 deletions(-) create mode 100644 artifacts/issues-36-38-e2e/e2e-evidence.json create mode 100644 artifacts/issues-36-38-e2e/nvda-fy2025-report.html create mode 100644 artifacts/issues-36-38-e2e/nvda-fy2025-report.json create mode 100644 artifacts/issues-36-38-e2e/nvda-fy2025-report.md create mode 100644 artifacts/issues-36-38-e2e/nvda-fy2025-report.png create mode 100644 packages/core/src/background-job.ts create mode 100644 packages/core/src/portfolio-context.ts create mode 100644 packages/shared/src/automation/job-engine.test.ts create mode 100644 packages/shared/src/automation/job-engine.ts create mode 100644 packages/shared/src/context/index.ts create mode 100644 packages/shared/src/context/repository.test.ts create mode 100644 packages/shared/src/context/repository.ts create mode 100644 packages/shared/src/export/artifact.test.ts create mode 100644 packages/shared/src/export/artifact.ts create mode 100644 packages/ui/src/atoms/contextAtoms.test.ts create mode 100644 packages/ui/src/atoms/contextAtoms.ts create mode 100644 scripts/eval/issues-36-38-e2e.ts diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index fca92c9..64396e3 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -525,10 +525,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 826499e..1944e61 100644 --- a/apps/electron/src/main/kernelHost.test.ts +++ b/apps/electron/src/main/kernelHost.test.ts @@ -247,8 +247,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). @@ -330,10 +353,8 @@ describe('AgentKernelHost', () => { it('builds the kernel on the electron userData store', () => { const host = new AgentKernelHost(); - expect(lastKernelOptions).toMatchObject({ - storageDir: '/tmp/finagent-test/store', - piSessionDir: '/tmp/finagent-test/pi-sessions', - }); + expect(String(lastKernelOptions?.storageDir).replaceAll('\\', '/')).toContain('/tmp/finagent-test/store'); + expect(String(lastKernelOptions?.piSessionDir).replaceAll('\\', '/')).toContain('/tmp/finagent-test/pi-sessions'); host.dispose(); }); @@ -436,4 +457,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 f971051..eac6f26 100644 --- a/apps/electron/src/main/kernelHost.ts +++ b/apps/electron/src/main/kernelHost.ts @@ -68,6 +68,11 @@ import type { ToolCall, ToolCallRecord, TraceReference, + BackgroundJob, + StoredNotification, + ContextSelection, + VersionedWatchlist, + VersionedPortfolioContext, } from '@finagent/core'; import { STRATEGY_IDS } from '@finagent/core'; import { isLocalePreference } from '@finagent/i18n'; @@ -126,6 +131,8 @@ import { parseCsv, parsePaste, reportToMarkdown, + reportToHtml, + reportToJson, reportToShareCard, redactForShare, computeSkillCalibrations, @@ -147,6 +154,9 @@ import { type MarketPulseSnapshot, type ShareCard, type WatchlistQuote, + PortfolioContextRepository, + BackgroundJobRepository, + BackgroundJobScheduler, } from '@finagent/shared'; import { LongbridgeBrokerAccountProvider, @@ -248,10 +258,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; @@ -278,6 +291,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 @@ -348,6 +362,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({ @@ -360,6 +375,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') }); @@ -487,17 +508,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 { @@ -1843,6 +1905,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')); @@ -1915,7 +2047,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 { @@ -1968,7 +2102,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. @@ -2135,6 +2277,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; @@ -2246,6 +2391,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 5cc792a..2137f18 100644 --- a/apps/electron/src/preload/index.cjs +++ b/apps/electron/src/preload/index.cjs @@ -208,8 +208,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 7acffc2..aeb76cd 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -155,8 +155,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; @@ -363,8 +380,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 0000000000000000000000000000000000000000..18274fea2271b6bab90f6e33ef14678ef0208387 GIT binary patch literal 91969 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 + 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 beb62c5..2204d80 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'; export type { SupportedLocale, LocalePreference } from './locale.ts'; @@ -393,6 +394,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 { @@ -564,3 +571,5 @@ 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'; 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 8202715..c21c91f 100644 --- a/packages/core/src/research.ts +++ b/packages/core/src/research.ts @@ -32,6 +32,17 @@ export interface EvidenceRef { fetchedAt: number; /** Short factual summary of the data point (from CapabilityResult.summary). */ summary?: string; + /** 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. */ @@ -81,6 +92,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 9eab4f7..0580fcf 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 c4fe229..b3d7f0d 100644 --- a/packages/i18n/src/locales/en-US/research.ts +++ b/packages/i18n/src/locales/en-US/research.ts @@ -236,6 +236,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 0dc845c..a3dc69c 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 096dfbb..0d5aa21 100644 --- a/packages/i18n/src/locales/zh-CN/research.ts +++ b/packages/i18n/src/locales/zh-CN/research.ts @@ -223,6 +223,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 08c0eff..3db5623 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/pi-runtime-agent-backend.test.ts b/packages/shared/src/agent/pi-runtime-agent-backend.test.ts index 8649b6e..a2196df 100644 --- a/packages/shared/src/agent/pi-runtime-agent-backend.test.ts +++ b/packages/shared/src/agent/pi-runtime-agent-backend.test.ts @@ -526,7 +526,7 @@ describe('PiRuntimeAdapter', () => { await adapter.ensureSession({ id: 's2' }); await adapter.ensureSession({ id: 's1' }); - expect(switched).toEqual(['/tmp/pi/s1.jsonl', '/tmp/pi/s2.jsonl', '/tmp/pi/s1.jsonl']); + expect(switched.map((path) => path.replaceAll('\\', '/'))).toEqual(['/tmp/pi/s1.jsonl', '/tmp/pi/s2.jsonl', '/tmp/pi/s1.jsonl']); }); it('ends a cancelled run with a RUN_CANCELLED failure event', async () => { 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