Codex ended here and GLM took over wish me luck

This commit is contained in:
2026-04-09 17:43:55 -04:00
parent af6eb242be
commit 3a442f81f4
10 changed files with 853 additions and 140 deletions

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rusqlite::types::Value; use rusqlite::types::Value;
use rusqlite::{params, params_from_iter, Connection, OptionalExtension}; use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
@@ -14,6 +15,7 @@ use crate::news::{NewsError, Result};
#[derive(Clone)] #[derive(Clone)]
pub struct NewsRepository { pub struct NewsRepository {
db_path: PathBuf, db_path: PathBuf,
connections: Arc<Mutex<Vec<Connection>>>,
} }
impl NewsRepository { impl NewsRepository {
@@ -22,9 +24,13 @@ impl NewsRepository {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let repository = Self { db_path }; let connection = open_connection(&db_path)?;
let connection = repository.open_connection()?; let repository = Self {
db_path,
connections: Arc::new(Mutex::new(Vec::new())),
};
repository.initialize_schema(&connection)?; repository.initialize_schema(&connection)?;
repository.store_connection(connection)?;
Ok(repository) Ok(repository)
} }
@@ -325,13 +331,24 @@ impl NewsRepository {
T: Send + 'static, T: Send + 'static,
{ {
let db_path = self.db_path.clone(); let db_path = self.db_path.clone();
let connections = self.connections.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut connection = open_connection(&db_path)?; let mut connection = take_connection(&connections, &db_path)?;
task(&mut connection) let result = task(&mut connection);
let store_result = store_connection(&connections, connection);
match (result, store_result) {
(Ok(value), Ok(())) => Ok(value),
(Err(error), Ok(())) => Err(error),
(Ok(_), Err(error)) | (Err(_), Err(error)) => Err(error),
}
}) })
.await .await
.map_err(|error| NewsError::Join(error.to_string()))? .map_err(|error| NewsError::Join(error.to_string()))?
} }
fn store_connection(&self, connection: Connection) -> Result<()> {
store_connection(&self.connections, connection)
}
} }
fn sync_sources_in_connection( fn sync_sources_in_connection(
@@ -388,6 +405,27 @@ fn open_connection(path: &Path) -> Result<Connection> {
Ok(connection) Ok(connection)
} }
fn take_connection(pool: &Mutex<Vec<Connection>>, db_path: &Path) -> Result<Connection> {
{
let mut guard = pool.lock().map_err(|error| {
NewsError::Config(format!("news connection pool poisoned: {error}"))
})?;
if let Some(connection) = guard.pop() {
return Ok(connection);
}
}
open_connection(db_path)
}
fn store_connection(pool: &Mutex<Vec<Connection>>, connection: Connection) -> Result<()> {
let mut guard = pool
.lock()
.map_err(|error| NewsError::Config(format!("news connection pool poisoned: {error}")))?;
guard.push(connection);
Ok(())
}
fn query_articles( fn query_articles(
connection: &mut Connection, connection: &mut Connection,
request: QueryNewsFeedRequest, request: QueryNewsFeedRequest,

View File

@@ -66,13 +66,6 @@ impl ResearchPipeline {
self.repository.enqueue_jobs(jobs).await self.repository.enqueue_jobs(jobs).await
} }
pub async fn mark_running(&self, mut job: PipelineJob) -> Result<PipelineJob> {
job.status = JobStatus::Running;
job.attempt_count += 1;
job.updated_at = now_rfc3339();
self.repository.save_job(job).await
}
pub async fn mark_completed(&self, mut job: PipelineJob) -> Result<PipelineJob> { pub async fn mark_completed(&self, mut job: PipelineJob) -> Result<PipelineJob> {
job.status = JobStatus::Completed; job.status = JobStatus::Completed;
job.last_error = None; job.last_error = None;
@@ -92,13 +85,13 @@ impl ResearchPipeline {
pub async fn mark_failed(&self, mut job: PipelineJob, error: &str) -> Result<PipelineJob> { pub async fn mark_failed(&self, mut job: PipelineJob, error: &str) -> Result<PipelineJob> {
job.status = JobStatus::Failed; job.status = JobStatus::Failed;
job.last_error = Some(error.to_string()); job.last_error = Some(error.to_string());
job.next_attempt_at = Some(next_retry_timestamp(job.attempt_count + 1)); job.next_attempt_at = Some(next_retry_timestamp(job.attempt_count));
job.updated_at = now_rfc3339(); job.updated_at = now_rfc3339();
self.repository.save_job(job).await self.repository.save_job(job).await
} }
pub async fn due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> { pub async fn claim_due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> {
self.repository.list_due_jobs(limit).await self.repository.claim_due_jobs(limit).await
} }
} }

View File

@@ -1,8 +1,11 @@
//! SQLite-backed persistence for research workspaces, notes, links, ghosts, and jobs. //! SQLite-backed persistence for research workspaces, notes, links, ghosts, and jobs.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::types::Value as SqlValue;
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
use crate::research::errors::{ResearchError, Result}; use crate::research::errors::{ResearchError, Result};
use crate::research::types::{ use crate::research::types::{
@@ -13,6 +16,7 @@ use crate::research::types::{
#[derive(Clone)] #[derive(Clone)]
pub struct ResearchRepository { pub struct ResearchRepository {
db_path: PathBuf, db_path: PathBuf,
connections: Arc<Mutex<Vec<Connection>>>,
} }
impl ResearchRepository { impl ResearchRepository {
@@ -21,9 +25,13 @@ impl ResearchRepository {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let repository = Self { db_path }; let connection = open_connection(&db_path)?;
let connection = repository.open_connection()?; let repository = Self {
db_path,
connections: Arc::new(Mutex::new(Vec::new())),
};
repository.initialize_schema(&connection)?; repository.initialize_schema(&connection)?;
repository.store_connection(connection)?;
Ok(repository) Ok(repository)
} }
@@ -177,6 +185,64 @@ impl ResearchRepository {
.await .await
} }
pub async fn save_notes_batch(&self, notes: Vec<ResearchNote>) -> Result<()> {
self.with_connection(move |connection| {
let transaction = connection.transaction()?;
for note in &notes {
transaction.execute(
"INSERT INTO research_notes (
id, workspace_id, note_type, ticker, source_id, archived, pinned, revision,
evidence_status, created_at, updated_at, entity_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
ON CONFLICT(id) DO UPDATE SET
workspace_id = excluded.workspace_id,
note_type = excluded.note_type,
ticker = excluded.ticker,
source_id = excluded.source_id,
archived = excluded.archived,
pinned = excluded.pinned,
revision = excluded.revision,
evidence_status = excluded.evidence_status,
updated_at = excluded.updated_at,
entity_json = excluded.entity_json",
params![
&note.id,
&note.workspace_id,
serde_json::to_string(&note.note_type)?,
note.ticker.as_deref(),
note.source_id.as_deref(),
i64::from(note.archived),
i64::from(note.pinned),
i64::from(note.revision),
serde_json::to_string(&note.evidence_status)?,
&note.created_at,
&note.updated_at,
serde_json::to_string(note)?,
],
)?;
transaction.execute(
"DELETE FROM research_fts WHERE note_id = ?1",
params![&note.id],
)?;
transaction.execute(
"INSERT INTO research_fts (note_id, title, cleaned_text, ai_annotation)
VALUES (?1, ?2, ?3, ?4)",
params![
&note.id,
note.title.clone().unwrap_or_default(),
&note.cleaned_text,
note.ai_annotation.clone().unwrap_or_default(),
],
)?;
}
transaction.commit()?;
Ok(())
})
.await
}
pub async fn get_note(&self, note_id: &str) -> Result<ResearchNote> { pub async fn get_note(&self, note_id: &str) -> Result<ResearchNote> {
let note_id = note_id.to_string(); let note_id = note_id.to_string();
self.with_connection(move |connection| { self.with_connection(move |connection| {
@@ -319,20 +385,37 @@ impl ResearchRepository {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let ids = source_ids.to_vec(); let mut seen = HashSet::new();
let ids = source_ids
.iter()
.filter(|source_id| seen.insert((*source_id).clone()))
.cloned()
.collect::<Vec<_>>();
self.with_connection(move |connection| { self.with_connection(move |connection| {
let mut results = Vec::new(); let placeholders = std::iter::repeat_n("?", ids.len())
let mut statement = .collect::<Vec<_>>()
connection.prepare("SELECT entity_json FROM source_records WHERE id = ?1")?; .join(", ");
for source_id in ids { let query =
if let Some(json) = statement format!("SELECT id, entity_json FROM source_records WHERE id IN ({placeholders})");
.query_row(params![source_id], |row| row.get::<_, String>(0)) let query_params = ids.iter().cloned().map(SqlValue::Text).collect::<Vec<_>>();
.optional()? let mut statement = connection.prepare(&query)?;
{ let rows = statement.query_map(params_from_iter(query_params.iter()), |row| {
results.push(serde_json::from_str(&json)?); Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
} })?;
} let rows = rows.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(results) let sources_by_id = rows
.into_iter()
.map(|(id, json)| {
serde_json::from_str::<SourceRecord>(&json)
.map(|source| (id, source))
.map_err(ResearchError::from)
})
.collect::<Result<HashMap<_, _>>>()?;
Ok(ids
.into_iter()
.filter_map(|source_id| sources_by_id.get(&source_id).cloned())
.collect())
}) })
.await .await
} }
@@ -621,29 +704,57 @@ impl ResearchRepository {
Ok(value) Ok(value)
} }
pub async fn list_due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> { pub async fn claim_due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> {
let now = crate::research::util::now_rfc3339();
self.with_connection(move |connection| { self.with_connection(move |connection| {
let mut statement = connection.prepare( let transaction = connection.transaction()?;
let mut statement = transaction.prepare(
"SELECT entity_json "SELECT entity_json
FROM pipeline_jobs FROM pipeline_jobs
WHERE status IN (?1, ?2) WHERE status IN (?1, ?2)
AND (next_attempt_at IS NULL OR next_attempt_at <= datetime('now')) AND (next_attempt_at IS NULL OR next_attempt_at <= ?3)
ORDER BY updated_at ASC ORDER BY updated_at ASC
LIMIT ?3", LIMIT ?4",
)?; )?;
let rows = statement.query_map( let rows = statement.query_map(
params![ params![
serde_json::to_string(&JobStatus::Queued)?, serde_json::to_string(&JobStatus::Queued)?,
serde_json::to_string(&JobStatus::Failed)?, serde_json::to_string(&JobStatus::Failed)?,
now.clone(),
i64::try_from(limit) i64::try_from(limit)
.map_err(|error| ResearchError::Validation(error.to_string()))?, .map_err(|error| ResearchError::Validation(error.to_string()))?,
], ],
|row| row.get::<_, String>(0), |row| row.get::<_, String>(0),
)?; )?;
rows.collect::<std::result::Result<Vec<_>, _>>()? let mut jobs = rows
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter() .into_iter()
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from)) .map(|json| serde_json::from_str::<PipelineJob>(&json).map_err(ResearchError::from))
.collect() .collect::<Result<Vec<_>>>()?;
drop(statement);
for job in &mut jobs {
job.status = JobStatus::Running;
job.attempt_count += 1;
job.updated_at = now.clone();
transaction.execute(
"UPDATE pipeline_jobs
SET status = ?2,
updated_at = ?3,
entity_json = ?4
WHERE id = ?1",
params![
&job.id,
serde_json::to_string(&job.status)?,
&job.updated_at,
serde_json::to_string(job)?,
],
)?;
}
transaction.commit()?;
Ok(jobs)
}) })
.await .await
} }
@@ -759,10 +870,6 @@ impl ResearchRepository {
.await .await
} }
fn open_connection(&self) -> Result<Connection> {
open_connection(&self.db_path)
}
fn initialize_schema(&self, connection: &Connection) -> Result<()> { fn initialize_schema(&self, connection: &Connection) -> Result<()> {
connection.execute_batch( connection.execute_batch(
"PRAGMA foreign_keys = ON; "PRAGMA foreign_keys = ON;
@@ -799,6 +906,8 @@ impl ResearchRepository {
ON research_notes (workspace_id, note_type, archived); ON research_notes (workspace_id, note_type, archived);
CREATE INDEX IF NOT EXISTS research_notes_workspace_ticker_updated_idx CREATE INDEX IF NOT EXISTS research_notes_workspace_ticker_updated_idx
ON research_notes (workspace_id, ticker, updated_at DESC); ON research_notes (workspace_id, ticker, updated_at DESC);
CREATE INDEX IF NOT EXISTS research_notes_workspace_source_type_idx
ON research_notes (workspace_id, source_id, note_type);
CREATE TABLE IF NOT EXISTS note_links ( CREATE TABLE IF NOT EXISTS note_links (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE,
@@ -831,6 +940,10 @@ impl ResearchRepository {
); );
CREATE INDEX IF NOT EXISTS source_records_workspace_ticker_kind_published_idx CREATE INDEX IF NOT EXISTS source_records_workspace_ticker_kind_published_idx
ON source_records (workspace_id, ticker, kind, published_at DESC); ON source_records (workspace_id, ticker, kind, published_at DESC);
CREATE INDEX IF NOT EXISTS source_records_workspace_checksum_idx
ON source_records (workspace_id, json_extract(entity_json, '$.checksum'));
CREATE INDEX IF NOT EXISTS source_records_workspace_accession_idx
ON source_records (workspace_id, json_extract(entity_json, '$.filingAccession'));
CREATE TABLE IF NOT EXISTS source_excerpts ( CREATE TABLE IF NOT EXISTS source_excerpts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
source_id TEXT NOT NULL, source_id TEXT NOT NULL,
@@ -865,6 +978,10 @@ impl ResearchRepository {
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
entity_json TEXT NOT NULL entity_json TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS audit_events_entity_created_idx
ON audit_events (entity_id, created_at ASC);
CREATE INDEX IF NOT EXISTS audit_events_workspace_created_idx
ON audit_events (workspace_id, created_at ASC);
CREATE VIRTUAL TABLE IF NOT EXISTS research_fts USING fts5(note_id UNINDEXED, title, cleaned_text, ai_annotation);", CREATE VIRTUAL TABLE IF NOT EXISTS research_fts USING fts5(note_id UNINDEXED, title, cleaned_text, ai_annotation);",
)?; )?;
Ok(()) Ok(())
@@ -876,13 +993,24 @@ impl ResearchRepository {
T: Send + 'static, T: Send + 'static,
{ {
let db_path = self.db_path.clone(); let db_path = self.db_path.clone();
let connections = self.connections.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut connection = open_connection(&db_path)?; let mut connection = take_connection(&connections, &db_path)?;
task(&mut connection) let result = task(&mut connection);
let store_result = store_connection(&connections, connection);
match (result, store_result) {
(Ok(value), Ok(())) => Ok(value),
(Err(error), Ok(())) => Err(error),
(Ok(_), Err(error)) | (Err(_), Err(error)) => Err(error),
}
}) })
.await .await
.map_err(|error| ResearchError::Join(error.to_string()))? .map_err(|error| ResearchError::Join(error.to_string()))?
} }
fn store_connection(&self, connection: Connection) -> Result<()> {
store_connection(&self.connections, connection)
}
} }
fn open_connection(path: &Path) -> Result<Connection> { fn open_connection(path: &Path) -> Result<Connection> {
@@ -895,6 +1023,27 @@ fn open_connection(path: &Path) -> Result<Connection> {
Ok(connection) Ok(connection)
} }
fn take_connection(pool: &Mutex<Vec<Connection>>, db_path: &Path) -> Result<Connection> {
{
let mut guard = pool.lock().map_err(|error| {
ResearchError::Validation(format!("research connection pool poisoned: {error}"))
})?;
if let Some(connection) = guard.pop() {
return Ok(connection);
}
}
open_connection(db_path)
}
fn store_connection(pool: &Mutex<Vec<Connection>>, connection: Connection) -> Result<()> {
let mut guard = pool.lock().map_err(|error| {
ResearchError::Validation(format!("research connection pool poisoned: {error}"))
})?;
guard.push(connection);
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use tempfile::tempdir; use tempfile::tempdir;

View File

@@ -1,5 +1,6 @@
//! Public orchestration service for the research subsystem. //! Public orchestration service for the research subsystem.
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use serde_json::Value; use serde_json::Value;
@@ -38,6 +39,7 @@ pub struct ResearchService<R: Runtime> {
ai_gateway: Arc<dyn ResearchAiGateway>, ai_gateway: Arc<dyn ResearchAiGateway>,
emitter: ResearchEventEmitter<R>, emitter: ResearchEventEmitter<R>,
settings: AgentSettingsService<R>, settings: AgentSettingsService<R>,
job_processor_lock: Arc<tokio::sync::Mutex<()>>,
} }
impl<R: Runtime> Clone for ResearchService<R> { impl<R: Runtime> Clone for ResearchService<R> {
@@ -48,6 +50,7 @@ impl<R: Runtime> Clone for ResearchService<R> {
ai_gateway: self.ai_gateway.clone(), ai_gateway: self.ai_gateway.clone(),
emitter: self.emitter.clone(), emitter: self.emitter.clone(),
settings: self.settings.clone(), settings: self.settings.clone(),
job_processor_lock: self.job_processor_lock.clone(),
} }
} }
} }
@@ -73,6 +76,7 @@ impl<R: Runtime + 'static> ResearchService<R> {
ai_gateway, ai_gateway,
emitter: ResearchEventEmitter::new(app_handle), emitter: ResearchEventEmitter::new(app_handle),
settings: AgentSettingsService::new(app_handle), settings: AgentSettingsService::new(app_handle),
job_processor_lock: Arc::new(tokio::sync::Mutex::new(())),
}) })
} }
@@ -207,6 +211,7 @@ impl<R: Runtime + 'static> ResearchService<R> {
source_note.source_id.iter().cloned().collect(), source_note.source_id.iter().cloned().collect(),
) )
.await?; .await?;
self.emitter.note_updated(&source_note);
} }
} }
@@ -428,22 +433,17 @@ impl<R: Runtime + 'static> ResearchService<R> {
&self, &self,
request: GetWorkspaceProjectionRequest, request: GetWorkspaceProjectionRequest,
) -> Result<WorkspaceProjection> { ) -> Result<WorkspaceProjection> {
let workspace = self.repository.get_workspace(&request.workspace_id).await?; let (workspace, notes, links, ghosts) = tokio::try_join!(
let notes = self self.repository.get_workspace(&request.workspace_id),
.repository self.repository
.list_notes(&request.workspace_id, false, None) .list_notes(&request.workspace_id, false, None),
.await?; self.repository.list_links(&request.workspace_id, None),
let links = self self.repository.list_ghosts(&request.workspace_id, false),
.repository )?;
.list_links(&request.workspace_id, None) let active_view = request.view.unwrap_or(workspace.default_view);
.await?;
let ghosts = self
.repository
.list_ghosts(&request.workspace_id, false)
.await?;
Ok(build_workspace_projection( Ok(build_workspace_projection(
workspace.clone(), workspace,
request.view.unwrap_or(workspace.default_view), active_view,
notes, notes,
links, links,
ghosts, ghosts,
@@ -559,27 +559,27 @@ impl<R: Runtime + 'static> ResearchService<R> {
request: GetNoteAuditTrailRequest, request: GetNoteAuditTrailRequest,
) -> Result<NoteAuditTrail> { ) -> Result<NoteAuditTrail> {
let note = self.repository.get_note(&request.note_id).await?; let note = self.repository.get_note(&request.note_id).await?;
let links = self let (links, ghosts, audit_events) = tokio::try_join!(
.repository self.repository
.list_links(&note.workspace_id, Some(&note.id)) .list_links(&note.workspace_id, Some(&note.id)),
.await?; self.repository.list_ghosts(&note.workspace_id, true),
let ghosts = self self.repository.list_audit_events_for_entity(&note.id),
.repository )?;
.list_ghosts(&note.workspace_id, true) let ghosts = ghosts
.await?
.into_iter() .into_iter()
.filter(|ghost| { .filter(|ghost| {
ghost.supporting_note_ids.contains(&note.id) ghost.supporting_note_ids.contains(&note.id)
|| ghost.contradicting_note_ids.contains(&note.id) || ghost.contradicting_note_ids.contains(&note.id)
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut source_ids = note.source_id.iter().cloned().collect::<Vec<_>>(); let source_ids = dedupe_ids(
source_ids.extend(ghosts.iter().flat_map(|ghost| ghost.source_ids.clone())); note.source_id.iter().cloned().chain(
ghosts
.iter()
.flat_map(|ghost| ghost.source_ids.iter().cloned()),
),
);
let sources = self.repository.list_sources_by_ids(&source_ids).await?; let sources = self.repository.list_sources_by_ids(&source_ids).await?;
let audit_events = self
.repository
.list_audit_events_for_entity(&note.id)
.await?;
let memo_blocks = build_memo_blocks(std::slice::from_ref(&note), &ghosts); let memo_blocks = build_memo_blocks(std::slice::from_ref(&note), &ghosts);
Ok(NoteAuditTrail { Ok(NoteAuditTrail {
@@ -619,30 +619,40 @@ impl<R: Runtime + 'static> ResearchService<R> {
} }
pub async fn process_due_jobs(&self) -> Result<()> { pub async fn process_due_jobs(&self) -> Result<()> {
let jobs = self.pipeline.due_jobs(16).await?; let Ok(_guard) = self.job_processor_lock.try_lock() else {
for job in jobs { return Ok(());
let running = self.pipeline.mark_running(job).await?; };
self.emitter.job_updated(&running);
let result = self.process_job(&running).await; loop {
match result { let jobs = self.pipeline.claim_due_jobs(16).await?;
Ok(()) => { if jobs.is_empty() {
let completed = self.pipeline.mark_completed(running).await?; break;
self.emitter.job_updated(&completed); }
}
Err(error) => { for running in jobs {
let failed = if running.attempt_count >= running.max_attempts { self.emitter.job_updated(&running);
self.pipeline let result = self.process_job(&running).await;
.mark_skipped(running, &error.to_string()) match result {
.await? Ok(()) => {
} else { let completed = self.pipeline.mark_completed(running).await?;
self.pipeline self.emitter.job_updated(&completed);
.mark_failed(running, &error.to_string()) }
.await? Err(error) => {
}; let failed = if running.attempt_count >= running.max_attempts {
self.emitter.job_updated(&failed); self.pipeline
.mark_skipped(running, &error.to_string())
.await?
} else {
self.pipeline
.mark_failed(running, &error.to_string())
.await?
};
self.emitter.job_updated(&failed);
}
} }
} }
} }
Ok(()) Ok(())
} }
@@ -733,20 +743,30 @@ impl<R: Runtime + 'static> ResearchService<R> {
.replace_links_for_workspace(&workspace_id, links.clone()) .replace_links_for_workspace(&workspace_id, links.clone())
.await?; .await?;
let now = now_rfc3339(); let now = now_rfc3339();
let mut note_map = notes let changed_notes = notes
.into_iter() .into_iter()
.map(|mut note| { .filter_map(|mut note| {
note.inferred_links = saved_links let inferred_links = saved_links
.iter() .iter()
.filter(|link| link.from_note_id == note.id || link.to_note_id == note.id) .filter(|link| link.from_note_id == note.id || link.to_note_id == note.id)
.map(|link| link.id.clone()) .map(|link| link.id.clone())
.collect(); .collect::<Vec<_>>();
if note.inferred_links == inferred_links {
return None;
}
note.inferred_links = inferred_links;
note.last_linked_at = Some(now.clone()); note.last_linked_at = Some(now.clone());
(note.id.clone(), note) Some(note)
}) })
.collect::<std::collections::BTreeMap<_, _>>(); .collect::<Vec<_>>();
for note in note_map.values_mut() { if !changed_notes.is_empty() {
self.repository.save_note(note.clone()).await?; self.repository
.save_notes_batch(changed_notes.clone())
.await?;
for note in &changed_notes {
self.emitter.note_updated(note);
}
} }
for link in &saved_links { for link in &saved_links {
self.record_audit( self.record_audit(
@@ -882,6 +902,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
} }
} }
fn dedupe_ids<I>(ids: I) -> Vec<String>
where
I: IntoIterator<Item = String>,
{
let mut seen = HashSet::new();
ids.into_iter()
.filter(|id| seen.insert(id.clone()))
.collect()
}
fn payload_required_str(payload: &Value, key: &str) -> Result<String> { fn payload_required_str(payload: &Value, key: &str) -> Result<String> {
payload payload
.get(key) .get(key)

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Pin, Archive, RefreshCw, Sparkles } from 'lucide-react'; import { Pin, Archive, RefreshCw, Sparkles } from 'lucide-react';
import { researchBridge } from '../../lib/researchBridge';
import type { import type {
GhostNote, GhostNote,
NoteAuditTrail, NoteAuditTrail,
@@ -14,6 +13,9 @@ import { GHOST_CLASS_LABELS, NOTE_TYPE_LABELS } from './primitives/researchMeta'
interface ResearchInspectorProps { interface ResearchInspectorProps {
note: ResearchNote | null; note: ResearchNote | null;
ghost: GhostNote | null; ghost: GhostNote | null;
auditTrail: NoteAuditTrail | null;
isLoadingAuditTrail: boolean;
onRefreshAuditTrail: () => void;
onUpdateNote: (noteId: string, patch: { onUpdateNote: (noteId: string, patch: {
rawText?: string; rawText?: string;
title?: string; title?: string;
@@ -44,6 +46,9 @@ const editableNoteTypes: NoteType[] = [
export const ResearchInspector: React.FC<ResearchInspectorProps> = ({ export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
note, note,
ghost, ghost,
auditTrail,
isLoadingAuditTrail,
onRefreshAuditTrail,
onUpdateNote, onUpdateNote,
onArchiveNote, onArchiveNote,
onPromoteNote, onPromoteNote,
@@ -52,24 +57,15 @@ export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
const [draftTitle, setDraftTitle] = useState(''); const [draftTitle, setDraftTitle] = useState('');
const [draftBody, setDraftBody] = useState(''); const [draftBody, setDraftBody] = useState('');
const [draftType, setDraftType] = useState<NoteType>('claim'); const [draftType, setDraftType] = useState<NoteType>('claim');
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
const [isLoadingAudit, setIsLoadingAudit] = useState(false);
useEffect(() => { useEffect(() => {
if (!note) { if (!note) {
setAuditTrail(null);
return; return;
} }
setDraftTitle(note.title ?? ''); setDraftTitle(note.title ?? '');
setDraftBody(note.rawText); setDraftBody(note.rawText);
setDraftType(note.noteType); setDraftType(note.noteType);
setIsLoadingAudit(true);
void researchBridge.getNoteAuditTrail({ noteId: note.id }).then((trail) => {
setAuditTrail(trail);
}).finally(() => {
setIsLoadingAudit(false);
});
}, [note]); }, [note]);
if (!note && !ghost) { if (!note && !ghost) {
@@ -227,21 +223,12 @@ export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
<div> <div>
<div className="text-[10px] uppercase tracking-[0.18em] text-[#7d90a7]">Audit Trail</div> <div className="text-[10px] uppercase tracking-[0.18em] text-[#7d90a7]">Audit Trail</div>
<div className="mt-1 text-sm text-[#dcecff]"> <div className="mt-1 text-sm text-[#dcecff]">
{isLoadingAudit ? 'Loading evidence trace...' : `${auditTrail?.links.length ?? 0} links, ${auditTrail?.sources.length ?? 0} sources`} {isLoadingAuditTrail ? 'Loading evidence trace...' : `${auditTrail?.links.length ?? 0} links, ${auditTrail?.sources.length ?? 0} sources`}
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={() => { onClick={onRefreshAuditTrail}
if (note) {
setIsLoadingAudit(true);
void researchBridge.getNoteAuditTrail({ noteId: note.id }).then((trail) => {
setAuditTrail(trail);
}).finally(() => {
setIsLoadingAudit(false);
});
}
}}
className="inline-flex items-center gap-2 rounded-xl border border-[#273447] px-3 py-2 text-xs uppercase tracking-[0.18em] text-[#8aaacb] transition-colors hover:border-[#4c6a8a] hover:text-white" className="inline-flex items-center gap-2 rounded-xl border border-[#273447] px-3 py-2 text-xs uppercase tracking-[0.18em] text-[#8aaacb] transition-colors hover:border-[#4c6a8a] hover:text-white"
> >
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />

View File

@@ -81,6 +81,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
const deferredSearch = useDeferredValue(filters.search); const deferredSearch = useDeferredValue(filters.search);
const [memoDraft, setMemoDraft] = useState(''); const [memoDraft, setMemoDraft] = useState('');
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null); const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
useEffect(() => { useEffect(() => {
if (!activeWorkspaceId) { if (!activeWorkspaceId) {
@@ -126,6 +127,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
]); ]);
const projection = projectionState.projection; const projection = projectionState.projection;
const currentWorkspace = projection?.workspace ?? workspaceState.workspace;
const filteredNotes = useMemo(() => { const filteredNotes = useMemo(() => {
const notes = projection?.notes ?? []; const notes = projection?.notes ?? [];
@@ -166,18 +168,37 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
const selectedNote = filteredNotes.find((note) => selection.selectedNoteIds.includes(note.id)) ?? null; const selectedNote = filteredNotes.find((note) => selection.selectedNoteIds.includes(note.id)) ?? null;
const selectedGhost = filteredGhosts.find((ghost) => ghost.id === selection.selectedGhostId) ?? null; const selectedGhost = filteredGhosts.find((ghost) => ghost.id === selection.selectedGhostId) ?? null;
const refreshAuditTrail = React.useCallback(async (noteId: string) => {
setIsLoadingAuditTrail(true);
try {
const trail = await researchBridge.getNoteAuditTrail({ noteId });
setAuditTrail(trail);
} finally {
setIsLoadingAuditTrail(false);
}
}, []);
useEffect(() => { useEffect(() => {
if (!selectedNote) { if (!selectedNote) {
setAuditTrail(null); setAuditTrail(null);
setIsLoadingAuditTrail(false);
return; return;
} }
let cancelled = false; let cancelled = false;
void researchBridge.getNoteAuditTrail({ noteId: selectedNote.id }).then((trail) => { setIsLoadingAuditTrail(true);
if (!cancelled) { void researchBridge
setAuditTrail(trail); .getNoteAuditTrail({ noteId: selectedNote.id })
} .then((trail) => {
}); if (!cancelled) {
setAuditTrail(trail);
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingAuditTrail(false);
}
});
return () => { return () => {
cancelled = true; cancelled = true;
@@ -340,24 +361,23 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
<ResearchCaptureBar <ResearchCaptureBar
workspaces={workspaces} workspaces={workspaces}
defaultWorkspaceId={activeWorkspaceId} defaultWorkspaceId={activeWorkspaceId}
defaultTicker={workspaceState.workspace?.primaryTicker ?? navigationIntent?.ticker} defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker}
contextLabel={workspaceState.workspace ? `${workspaceState.workspace.primaryTicker} workspace` : 'Quick capture'} contextLabel={currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture'}
onWorkspaceChange={onSelectWorkspace} onWorkspaceChange={onSelectWorkspace}
onEnsureWorkspace={onEnsureWorkspace} onEnsureWorkspace={onEnsureWorkspace}
onSubmitCapture={(draft) => onSubmitCapture={(draft) =>
onCaptureResearchNote({ onCaptureResearchNote({
draft, draft,
fallbackTicker: workspaceState.workspace?.primaryTicker ?? navigationIntent?.ticker, fallbackTicker: currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
explicitWorkspaceId: activeWorkspaceId, explicitWorkspaceId: activeWorkspaceId,
autoCreateFromTicker: Boolean( autoCreateFromTicker: Boolean(
workspaceState.workspace?.primaryTicker ?? navigationIntent?.ticker, currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
), ),
}) })
} }
onCaptured={(note) => { onCaptured={(note) => {
onSelectWorkspace(note.workspaceId); onSelectWorkspace(note.workspaceId);
selection.selectNote(note.id); selection.selectNote(note.id);
void projectionState.refreshProjection();
}} }}
/> />
</div> </div>
@@ -378,9 +398,13 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
} }
toolbar={ toolbar={
<ResearchToolbar <ResearchToolbar
workspace={workspaceState.workspace} workspace={currentWorkspace}
visibleGhostCount={visibleGhosts.length} visibleGhostCount={visibleGhosts.length}
activeJobCount={Object.values(projectionState.jobs).length} activeJobCount={
Object.values(projectionState.jobs).filter(
(job) => job.status === 'queued' || job.status === 'running',
).length
}
density={prefs.density} density={prefs.density}
onDensityChange={setDensity} onDensityChange={setDensity}
onToggleInspector={toggleInspector} onToggleInspector={toggleInspector}
@@ -392,6 +416,13 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
<ResearchInspector <ResearchInspector
note={selectedNote} note={selectedNote}
ghost={selectedGhost} ghost={selectedGhost}
auditTrail={auditTrail}
isLoadingAuditTrail={isLoadingAuditTrail}
onRefreshAuditTrail={() => {
if (selectedNote) {
void refreshAuditTrail(selectedNote.id);
}
}}
onUpdateNote={(noteId, patch) => workspaceState.updateNote({ noteId, ...patch })} onUpdateNote={(noteId, patch) => workspaceState.updateNote({ noteId, ...patch })}
onArchiveNote={(noteId, archived) => workspaceState.archiveNote(noteId, archived)} onArchiveNote={(noteId, archived) => workspaceState.archiveNote(noteId, archived)}
onPromoteNote={(noteId, thesisStatus, noteType) => onPromoteNote={(noteId, thesisStatus, noteType) =>

View File

@@ -7,9 +7,17 @@ import {
useRef, useRef,
} from 'react'; } from 'react';
import { useResearchEventSubscriptions } from '../lib/researchEvents'; import { useResearchEventSubscriptions } from '../lib/researchEvents';
import {
replaceProjectionWorkspace,
upsertProjectionGhost,
upsertProjectionNote,
} from '../lib/researchProjection';
import { researchBridge } from '../lib/researchBridge'; import { researchBridge } from '../lib/researchBridge';
import type { import type {
GhostNote,
PipelineJob, PipelineJob,
ResearchNote,
ResearchWorkspace,
WorkspaceProjection, WorkspaceProjection,
WorkspaceViewKind, WorkspaceViewKind,
} from '../types/research'; } from '../types/research';
@@ -26,6 +34,9 @@ type ProjectionAction =
| { type: 'load_started'; refresh: boolean } | { type: 'load_started'; refresh: boolean }
| { type: 'load_succeeded'; projection: WorkspaceProjection } | { type: 'load_succeeded'; projection: WorkspaceProjection }
| { type: 'load_failed'; error: string } | { type: 'load_failed'; error: string }
| { type: 'workspace_updated'; workspace: ResearchWorkspace }
| { type: 'note_updated'; note: ResearchNote }
| { type: 'ghost_updated'; ghost: GhostNote }
| { type: 'job_updated'; job: PipelineJob }; | { type: 'job_updated'; job: PipelineJob };
const createProjectionState = (): ProjectionState => ({ const createProjectionState = (): ProjectionState => ({
@@ -71,6 +82,27 @@ const projectionReducer = (
[action.job.id]: action.job, [action.job.id]: action.job,
}, },
}; };
case 'workspace_updated':
return state.projection
? {
...state,
projection: replaceProjectionWorkspace(state.projection, action.workspace),
}
: state;
case 'note_updated':
return state.projection
? {
...state,
projection: upsertProjectionNote(state.projection, action.note),
}
: state;
case 'ghost_updated':
return state.projection
? {
...state,
projection: upsertProjectionGhost(state.projection, action.ghost),
}
: state;
default: default:
return state; return state;
} }
@@ -132,19 +164,32 @@ export const useResearchProjection = (
useResearchEventSubscriptions({ useResearchEventSubscriptions({
workspaceId: workspaceId ?? undefined, workspaceId: workspaceId ?? undefined,
onWorkspaceUpdate: () => { onWorkspaceUpdate: (payload) => {
scheduleRefresh(); startTransition(() => {
dispatch({ type: 'workspace_updated', workspace: payload.workspace });
});
}, },
onNoteUpdate: () => { onNoteUpdate: (payload) => {
scheduleRefresh(); startTransition(() => {
dispatch({ type: 'note_updated', note: payload.note });
});
}, },
onGhostUpdate: () => { onGhostUpdate: (payload) => {
scheduleRefresh(); startTransition(() => {
dispatch({ type: 'ghost_updated', ghost: payload.ghost });
});
}, },
onJobUpdate: (payload) => { onJobUpdate: (payload) => {
startTransition(() => { startTransition(() => {
dispatch({ type: 'job_updated', job: payload.job }); dispatch({ type: 'job_updated', job: payload.job });
}); });
if (
payload.job.status === 'completed' &&
(payload.job.jobKind === 'infer_links' || payload.job.jobKind === 'evaluate_ghosts')
) {
scheduleRefresh();
}
}, },
}); });

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'bun:test';
import {
rebuildWorkspaceProjection,
upsertProjectionGhost,
upsertProjectionNote,
} from './researchProjection';
import type {
GhostNote,
ResearchNote,
ResearchWorkspace,
WorkspaceProjection,
} from '../types/research';
const workspace: ResearchWorkspace = {
id: 'workspace-1',
name: 'NVDA Research',
primaryTicker: 'NVDA',
scope: 'single_company',
stage: 'capture',
defaultView: 'canvas',
pinnedNoteIds: [],
archived: false,
createdAt: '2026-04-09T10:00:00Z',
updatedAt: '2026-04-09T10:00:00Z',
};
const note = (overrides: Partial<ResearchNote> = {}): ResearchNote => ({
id: 'note-1',
workspaceId: workspace.id,
ticker: 'NVDA',
rawText: 'Demand is improving.',
cleanedText: 'Demand is improving.',
title: 'Demand',
noteType: 'claim',
analystStatus: 'captured',
confidence: 0.7,
evidenceStatus: 'source_linked',
inferredLinks: [],
ghostStatus: 'none',
thesisStatus: 'none',
createdAt: '2026-04-09T10:00:00Z',
updatedAt: '2026-04-09T10:00:00Z',
provenance: {
createdBy: 'manual',
captureMethod: 'quick_entry',
sourceKind: 'manual',
createdAt: '2026-04-09T10:00:00Z',
rawInputHash: 'hash',
},
tags: [],
catalysts: [],
risks: [],
valuationRefs: [],
priority: 'normal',
pinned: false,
archived: false,
revision: 1,
...overrides,
});
const ghost = (overrides: Partial<GhostNote> = {}): GhostNote => ({
id: 'ghost-1',
workspaceId: workspace.id,
ghostClass: 'candidate_thesis',
headline: 'Candidate thesis',
body: 'Margins may expand.',
tone: 'tentative',
confidence: 0.8,
visibilityState: 'visible',
state: 'accepted',
supportingNoteIds: ['note-1'],
contradictingNoteIds: [],
sourceIds: ['source-1'],
evidenceThresholdMet: true,
createdAt: '2026-04-09T10:00:00Z',
updatedAt: '2026-04-09T10:00:00Z',
memoSectionHint: 'investment_memo',
...overrides,
});
const projection = (
notes: ResearchNote[] = [note()],
ghosts: GhostNote[] = [],
): WorkspaceProjection =>
rebuildWorkspaceProjection(
{
workspace,
activeView: 'canvas',
notes: [],
links: [],
ghosts: [],
memoBlocks: [],
graphNodes: [],
graphEdges: [],
kanbanColumns: [],
timelineEvents: [],
},
{ notes, ghosts },
);
describe('researchProjection helpers', () => {
it('upsertProjectionNote updates existing notes and recomputes derived views', () => {
const updated = upsertProjectionNote(
projection(),
note({
id: 'note-1',
noteType: 'event_takeaway',
title: 'Earnings reaction',
sourceExcerpt: {
sourceId: 'source-1',
locationLabel: 'Q1 2026',
},
}),
);
expect(updated.notes[0].noteType).toBe('event_takeaway');
expect(updated.timelineEvents).toEqual([
{
id: 'note-1',
label: 'Earnings reaction',
noteId: 'note-1',
at: 'Q1 2026',
},
]);
});
it('upsertProjectionGhost recomputes memo blocks for accepted ghosts', () => {
const updated = upsertProjectionGhost(projection(), ghost());
expect(updated.ghosts).toHaveLength(1);
expect(updated.memoBlocks.some((block) => block.headline === 'Candidate thesis')).toBe(true);
});
});

View File

@@ -0,0 +1,266 @@
import type {
GhostNote,
GraphEdge,
GraphNode,
KanbanColumn,
MemoBlockCandidate,
MemoSectionKind,
NoteLink,
NoteType,
ResearchNote,
ResearchWorkspace,
TimelineEvent,
WorkspaceProjection,
} from '../types/research';
const MEMO_ACCEPTED_THESIS_STATUSES = new Set([
'accepted_support',
'accepted_core',
'bull_case',
'bear_case',
] as const);
const BLOCKED_MEMO_NOTE_TYPES = new Set([
'question',
'follow_up_task',
'source_reference',
] as const);
const TIMELINE_NOTE_TYPES = new Set([
'event_takeaway',
'catalyst',
'management_signal',
] as const);
const GHOST_MEMO_STATES = new Set(['accepted', 'converted'] as const);
const noteSort = (left: ResearchNote, right: ResearchNote) => {
if (left.pinned !== right.pinned) {
return Number(right.pinned) - Number(left.pinned);
}
return right.updatedAt.localeCompare(left.updatedAt);
};
const ghostSort = (left: GhostNote, right: GhostNote) =>
right.updatedAt.localeCompare(left.updatedAt);
const sectionForNote = (noteType: NoteType): MemoSectionKind | null => {
switch (noteType) {
case 'thesis':
case 'sub_thesis':
case 'mosaic_insight':
return 'investment_memo';
case 'risk':
case 'contradiction':
return 'risk_register';
case 'catalyst':
return 'catalyst_calendar';
case 'valuation_point':
case 'scenario_assumption':
return 'valuation_write_up';
case 'event_takeaway':
return 'earnings_recap';
case 'channel_check':
return 'watchlist_update';
case 'fact':
case 'quote':
case 'management_signal':
case 'claim':
case 'industry_observation':
case 'competitor_comparison':
return 'stock_pitch';
case 'question':
case 'follow_up_task':
case 'source_reference':
case 'uncertainty':
return null;
}
};
const buildMemoBlocks = (
notes: ResearchNote[],
ghosts: GhostNote[],
): MemoBlockCandidate[] => {
const noteBlocks = notes.flatMap((note) => {
if (
note.archived ||
BLOCKED_MEMO_NOTE_TYPES.has(note.noteType as never) ||
note.evidenceStatus === 'unsourced'
) {
return [];
}
const sectionKind = sectionForNote(note.noteType);
if (!sectionKind) {
return [];
}
return [
{
sectionKind,
headline: note.title || note.cleanedText,
body: note.aiAnnotation || note.cleanedText,
sourceNoteIds: [note.id],
citationRefs: note.sourceId ? [note.sourceId] : [],
confidence: note.confidence,
accepted: MEMO_ACCEPTED_THESIS_STATUSES.has(note.thesisStatus as never),
},
];
});
const ghostBlocks = ghosts.flatMap((ghost) => {
if (!GHOST_MEMO_STATES.has(ghost.state as never) || !ghost.memoSectionHint) {
return [];
}
return [
{
sectionKind: ghost.memoSectionHint,
headline: ghost.headline,
body: ghost.body,
sourceNoteIds: ghost.supportingNoteIds,
citationRefs: ghost.sourceIds,
confidence: ghost.confidence,
accepted: true,
},
];
});
return [...noteBlocks, ...ghostBlocks];
};
const buildGraphNodes = (notes: ResearchNote[]): GraphNode[] =>
notes.map((note) => ({
id: note.id,
label: note.title || note.cleanedText,
kind: note.noteType,
confidence: note.confidence,
evidenceStatus: note.evidenceStatus,
}));
const buildGraphEdges = (links: NoteLink[]): GraphEdge[] =>
links.map((link) => ({
id: link.id,
from: link.fromNoteId,
to: link.toNoteId,
linkType: link.linkType,
strength: link.strength,
confidence: link.confidence,
}));
const buildKanbanColumns = (notes: ResearchNote[]): KanbanColumn[] => {
const noteTypes: NoteType[] = [
'fact',
'management_signal',
'claim',
'risk',
'catalyst',
'valuation_point',
'question',
'source_reference',
];
return noteTypes.map((noteType) => ({
key: noteType,
label:
noteType === 'valuation_point'
? 'Valuation'
: noteType === 'management_signal'
? 'Management Signal'
: noteType.replace(/_/g, ' '),
notes: notes
.filter((note) => note.noteType === noteType && !note.archived)
.sort(noteSort),
}));
};
const buildTimeline = (
notes: ResearchNote[],
ghosts: GhostNote[],
): TimelineEvent[] => {
const noteEvents = notes.flatMap((note) => {
if (!TIMELINE_NOTE_TYPES.has(note.noteType as never)) {
return [];
}
return [
{
id: note.id,
label: note.title || note.cleanedText,
noteId: note.id,
at: note.sourceExcerpt?.locationLabel,
},
];
});
const ghostEvents = ghosts.flatMap((ghost) => {
if (ghost.ghostClass !== 'contradiction_alert') {
return [];
}
return [
{
id: ghost.id,
label: ghost.headline,
noteId: ghost.supportingNoteIds[0] ?? ghost.id,
},
];
});
return [...noteEvents, ...ghostEvents];
};
const sortNotes = (notes: ResearchNote[]) => [...notes].sort(noteSort);
const sortGhosts = (ghosts: GhostNote[]) => [...ghosts].sort(ghostSort);
export const rebuildWorkspaceProjection = (
projection: WorkspaceProjection,
overrides: Partial<Pick<WorkspaceProjection, 'workspace' | 'notes' | 'links' | 'ghosts'>>,
): WorkspaceProjection => {
const workspace = overrides.workspace ?? projection.workspace;
const notes = sortNotes(overrides.notes ?? projection.notes);
const links = overrides.links ?? projection.links;
const ghosts = sortGhosts(overrides.ghosts ?? projection.ghosts);
return {
workspace,
activeView: projection.activeView,
notes,
links,
ghosts,
memoBlocks: buildMemoBlocks(notes, ghosts),
graphNodes: buildGraphNodes(notes),
graphEdges: buildGraphEdges(links),
kanbanColumns: buildKanbanColumns(notes),
timelineEvents: buildTimeline(notes, ghosts),
};
};
export const upsertProjectionNote = (
projection: WorkspaceProjection,
note: ResearchNote,
): WorkspaceProjection => {
const notes = projection.notes.some((current) => current.id === note.id)
? projection.notes.map((current) => (current.id === note.id ? note : current))
: [note, ...projection.notes];
return rebuildWorkspaceProjection(projection, { notes });
};
export const upsertProjectionGhost = (
projection: WorkspaceProjection,
ghost: GhostNote,
): WorkspaceProjection => {
const ghosts = projection.ghosts.some((current) => current.id === ghost.id)
? projection.ghosts.map((current) => (current.id === ghost.id ? ghost : current))
: [ghost, ...projection.ghosts];
return rebuildWorkspaceProjection(projection, { ghosts });
};
export const replaceProjectionWorkspace = (
projection: WorkspaceProjection,
workspace: ResearchWorkspace,
): WorkspaceProjection => rebuildWorkspaceProjection(projection, { workspace });

View File

@@ -30,4 +30,45 @@ export default defineConfig(async () => ({
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**"],
}, },
}, },
build: {
chunkSizeWarningLimit: 400,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) {
return undefined;
}
if (
id.includes("react-markdown") ||
id.includes("remark-gfm") ||
id.includes("mdast") ||
id.includes("micromark") ||
id.includes("hast") ||
id.includes("unist")
) {
return "markdown";
}
if (id.includes("recharts") || id.includes("/d3-")) {
return "charts";
}
if (
id.includes("/react/") ||
id.includes("react-dom") ||
id.includes("scheduler")
) {
return "react-vendor";
}
if (id.includes("@tauri-apps")) {
return "tauri";
}
return undefined;
},
},
},
},
})); }));